|
| 1 | +--- |
| 2 | +schema: cxf-library/fault-card/v1 |
| 3 | +id: AHU-FC-056 |
| 4 | +name: Supply air temperature hunting / oscillation |
| 5 | +equipment: ahu |
| 6 | +status: verified |
| 7 | +phase: 2 |
| 8 | +method: statistical |
| 9 | +severity: 3 |
| 10 | +category: COMFORT_ENERGY |
| 11 | +confidence: LOW |
| 12 | +estimation_method: QUALITATIVE_ONLY |
| 13 | +source: |
| 14 | + - "HVAC FDD Reference v1.0 §9, AHU-FC-056" |
| 15 | + - "Research-backed; PID instability detection" |
| 16 | +g36: null |
| 17 | +clusters: [] |
| 18 | +suppresses: [] |
| 19 | +suppressed_by: [] |
| 20 | +related: [AHU-FC-004] |
| 21 | +playbooks: [] |
| 22 | +operating_states: "all (fan running)" |
| 23 | +preconditions: "Supply fan running — SAT scatter means nothing in a dead air stream. No verdict within long_window (2 h) of engine start: both moving averages divide by elapsed time while their windows fill, so the baseline is not yet established and the ratio test can be satisfied by a warmup artifact — the host reports NO_EVAL for that period. SAT sensor integrity is a precondition, not a conclusion: a sensor that flatlines and then jumps produces this same scatter signature (diagnosis 4), so sensor checks clear first. When any gate is unmet the verdict is NO_EVAL, not healthy." |
| 24 | +points: |
| 25 | + - sat |
| 26 | +outputs: |
| 27 | + - name: yFault |
| 28 | + description: True while short-window SAT deviation has stayed above oscillation_threshold and above k × the long-window deviation for at least alarm_delay |
| 29 | +params: |
| 30 | + oscillation_threshold: |
| 31 | + default: 1.2 |
| 32 | + unit: "°C" |
| 33 | + description: "Short-window mean absolute deviation of SAT above which scatter counts as oscillation. MAD units, not standard deviation: the reference's 1.5 °C rolling std-dev corresponds to 1.20 MAD under a Gaussian reading (std = 1.2533 × MAD) and 1.35 under a pure-sine reading (std = 1.1107 × MAD); the default takes the lower bound" |
| 34 | + cxf: absHigh.t |
| 35 | + window: |
| 36 | + default: 900.0 |
| 37 | + unit: s |
| 38 | + description: Short (scatter) window, 15 min — drives both the mean stage and the deviation-averaging stage; a host must move both paths together |
| 39 | + cxf: [muShort.delta, madShort.delta] |
| 40 | + long_window: |
| 41 | + default: 7200.0 |
| 42 | + unit: s |
| 43 | + description: Long (baseline) window, 2 h — drives both the mean stage and the deviation-averaging stage; a host must move both paths together |
| 44 | + cxf: [muLong.delta, madLong.delta] |
| 45 | + k: |
| 46 | + default: 2.0 |
| 47 | + unit: ratio |
| 48 | + description: Multiple of the long-window deviation the short window must exceed for scatter to count as a departure from the unit's own baseline |
| 49 | + cxf: scaledLong.k |
| 50 | + alarm_delay: |
| 51 | + default: 1800.0 |
| 52 | + unit: s |
| 53 | + description: Continuous fault persistence required before the alarm asserts (30 min) |
| 54 | + cxf: persist.delayTime |
| 55 | +energy_impact: |
| 56 | + affected_subsystem: AHU control loop efficiency |
| 57 | + savings_range: 1-3% of AHU energy while hunting, from valve and damper cycling losses |
| 58 | + climate_sensitivity: neutral |
| 59 | + runtime_estimation: "none in-rule — QUALITATIVE_ONLY; size the opportunity from Energy Impact Reference §4.4 (hunting hours × AHU coil and fan power), not from this rule's output" |
| 60 | +emissions: |
| 61 | + scope: "2" |
| 62 | + method: QUALITATIVE_EMISSIONS |
| 63 | +verified: |
| 64 | + engine_rev: e2ff2f8 |
| 65 | + content_id: "cxf:fnv1a128:4a421aa419bd1f1fe626202c9cccf494" |
| 66 | + date: 2026-08-17 |
| 67 | +--- |
| 68 | + |
| 69 | +## Description |
| 70 | + |
| 71 | +Supply air temperature swings around its setpoint instead of settling on it. |
| 72 | +The signature is scatter, not offset: SAT may average exactly on setpoint while |
| 73 | +crossing it every few minutes. An oscillating loop keeps its valve or damper |
| 74 | +in continuous motion, wearing the actuator and burning coil energy on |
| 75 | +overshoot it then has to undo. The usual cause is proportional gain set too |
| 76 | +high (or integral time too short) for the coil's actual authority, often after |
| 77 | +a valve or actuator was replaced with a differently sized part and the loop was |
| 78 | +never retuned. |
| 79 | + |
| 80 | +The rule compares the unit against itself. Short-window scatter alone is a poor |
| 81 | +test — some AHUs simply run noisier than others — so a fault requires the |
| 82 | +recent scatter to be both absolutely large and several times the unit's own |
| 83 | +established baseline. Present in roughly 5% of buildings. Statistical method, |
| 84 | +severity 3 (warning): nothing here is unsafe, and the comfort penalty is |
| 85 | +usually mild, but the loop is doing work it does not need to do. |
| 86 | + |
| 87 | +## Detection Logic |
| 88 | + |
| 89 | +``` |
| 90 | +muShort = MovingAverage(sat, window) muLong = MovingAverage(sat, long_window) |
| 91 | +madShort = MovingAverage(|sat − muShort|, window) madLong = MovingAverage(|sat − muLong|, long_window) |
| 92 | +
|
| 93 | +yFault = (madShort > oscillation_threshold) absolute scatter test |
| 94 | + AND (madShort > k × madLong) onset test: scatter far above this unit's baseline |
| 95 | + sustained continuously for alarm_delay |
| 96 | +``` |
| 97 | + |
| 98 | +Block graph (`rule.cxf.jsonld`): |
| 99 | + |
| 100 | + |
| 101 | + |
| 102 | +Two identical chains run at two timescales. Each takes the moving average of |
| 103 | +SAT over its window (`muShort`, `muLong`), subtracts it from the live reading, |
| 104 | +takes the absolute value (`errShort`/`devShort`, `errLong`/`devLong`), and |
| 105 | +averages that deviation over the same window again (`madShort`, `madLong`). |
| 106 | +The result is a rolling mean absolute deviation — the engine has no rolling |
| 107 | +standard deviation, and MAD is the closest elementary-block proxy (see |
| 108 | +Deviations for the conversion). `absHigh` applies the absolute threshold to the |
| 109 | +short window; `scaledLong` and `relHigh` apply the ratio test. Both comparisons |
| 110 | +are strict, so scatter sitting exactly on the threshold or exactly at k times |
| 111 | +the baseline does not trip the rule. `persist` requires 30 minutes of |
| 112 | +continuous violation, which rides out one-off step disturbances — an economizer |
| 113 | +changeover or a setpoint reset spikes `madShort` for about one short window and |
| 114 | +then flushes out, well short of the timer. |
| 115 | + |
| 116 | +## Possible Diagnoses |
| 117 | + |
| 118 | +1. PID loop poorly tuned (oscillating) — gain too high or integral time too |
| 119 | + short for the coil's authority |
| 120 | +2. Valve or damper actuator hunting — worn linkage, sticking stem, or a |
| 121 | + positioner fighting its own feedback |
| 122 | +3. Conflicting control loops — two sequences acting on the same air stream |
| 123 | + (e.g. a coil loop and a face-and-bypass or mixing loop with overlapping |
| 124 | + ranges) |
| 125 | +4. Intermittent sensor signal — a loose SAT wire or failing transmitter reads |
| 126 | + as oscillation with no control defect present |
| 127 | + |
| 128 | +## Energy Impact |
| 129 | + |
| 130 | +COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no direct waste term |
| 131 | +to compute: an oscillating loop delivers roughly the right average temperature, |
| 132 | +and the loss is in the cycling itself — valve and damper strokes that overshoot |
| 133 | +and correct, coil energy spent on excursions that cancel out, and the fan and |
| 134 | +pump work that follows them. The reference puts this at 1–3% of AHU energy |
| 135 | +while the hunting lasts. Confidence is LOW because no controlled study isolates |
| 136 | +oscillation losses from the tuning changes that fix them; there is no PNNL |
| 137 | +measure for this fault. Climate-neutral — a poorly tuned loop hunts in any |
| 138 | +weather. Prevalence ~5%. Runtime estimation follows Energy Impact Reference |
| 139 | +§4.4 (hunting hours × AHU coil and fan power), applied by the host; this rule |
| 140 | +contributes the hours, not the kilowatts. |
| 141 | + |
| 142 | +## Emissions Impact |
| 143 | + |
| 144 | +Scope 2, QUALITATIVE_EMISSIONS, LOW confidence. Minimal in absolute terms — |
| 145 | +control-loop inefficiency, not a stuck-open coil. No avoided-emissions basis is |
| 146 | +published for this fault; a host that wants a number should apply its standard |
| 147 | +electricity factor to the fan and pump energy attributed above and treat the |
| 148 | +result as an order-of-magnitude estimate. |
| 149 | + |
| 150 | +## Deviations |
| 151 | + |
| 152 | +- **Rolling standard deviation → rolling mean absolute deviation.** The |
| 153 | + reference's logic is `rolling_std(SAT, window) > oscillation_threshold AND |
| 154 | + rolling_std(SAT, window) > k × rolling_std(SAT, long_window)`. The engine's |
| 155 | + elementary block set has no variance or standard-deviation block, and |
| 156 | + squaring inside a moving average is not expressible without one. MAD is |
| 157 | + computed instead, from four `Reals.MovingAverage` instances plus a subtract |
| 158 | + and an absolute value per timescale, exactly as the block graph shows. MAD |
| 159 | + and std are proportional for any fixed waveform, so the **ratio test carries |
| 160 | + over unchanged** — the scale factor appears on both sides of |
| 161 | + `madShort > k × madLong` and cancels. |
| 162 | +- **The absolute threshold does not carry over, so it is restated in MAD |
| 163 | + units.** For Gaussian noise `std = 1.2533 × MAD`; for a pure sine |
| 164 | + `std = 1.1107 × MAD`. The reference's 1.5 °C std therefore corresponds to a |
| 165 | + MAD of 1.20 (Gaussian) to 1.35 (sine). The default is **1.2, the lower |
| 166 | + bound**: it alarms at the same amplitude as the reference under the Gaussian |
| 167 | + reading and slightly earlier under the sine reading. A site that wants the |
| 168 | + conservative end of the band sets `oscillation_threshold` to 1.35. For a |
| 169 | + square wave — the waveform the vectors use — `std = MAD` exactly, so vector |
| 170 | + amplitudes read directly as MAD. |
| 171 | +- **`Reals.MovingAverage` is a continuous-time integral mean, not a sample |
| 172 | + mean.** The engine accumulates `u·dt` forward-Euler and divides by the window |
| 173 | + (`reals_filters.rs`), so hand-computed sample statistics do not match it |
| 174 | + exactly and the vector expectations were derived by replaying the graph at |
| 175 | + the pinned rev rather than by closed-form arithmetic. |
| 176 | +- **Minimum sample interval, from the same block.** Each `MovingAverage` |
| 177 | + instance keeps a fixed 64-checkpoint ring; more than 64 samples inside one |
| 178 | + window drops the oldest with a one-time warning. The tick interval must |
| 179 | + therefore be ≥ `long_window/64` = 7200/64 = **112.5 s** at the default |
| 180 | + windows. A host ticking faster than that silently shortens the baseline |
| 181 | + window — the fault still detects, but against a truncated baseline. The |
| 182 | + vectors step at 150 s rather than the library's usual 300 s: both clear the |
| 183 | + floor, but 150 s puts six samples in the short window and leaves two full |
| 184 | + steps of margin around every asserted transition. |
| 185 | +- **Warmup NO_EVAL.** The reference's "sufficient data in both windows" |
| 186 | + precondition is implemented host-side, not in the graph: during the first |
| 187 | + `long_window` after engine start `madLong` divides by elapsed time, so it |
| 188 | + underestimates the true baseline and oscillation present from the moment of |
| 189 | + load can transiently satisfy the ratio test. `delayOnInit = true` on |
| 190 | + `persist` buys 30 minutes of that back; the remaining exposure is covered by |
| 191 | + the frontmatter precondition, which requires the host to report NO_EVAL for |
| 192 | + the first 2 h. |
| 193 | +- **The short window is coarse at realistic tick rates.** 900 s at a 300 s BAS |
| 194 | + tick is three samples; `madShort` is a three-point statistic there and steps |
| 195 | + visibly. That is accepted — the threshold and the 30-minute persistence |
| 196 | + timer both absorb it — but a host with a faster trend interval gets a |
| 197 | + smoother short window at no cost (subject to the 112.5 s floor above). |
| 198 | +- **`window` and `long_window` each bind two CXF parameter paths** (the mean |
| 199 | + stage and the deviation-averaging stage), like `valve_open_threshold` in |
| 200 | + AHU-FC-059. Hosts must set both paths of a window together; splitting them |
| 201 | + changes what the statistic means. |
| 202 | +- **Both comparisons are strict** (`>`), and the boundary is pinned by |
| 203 | + bracketing rather than by an exact-equality tick: `madShort` is a computed |
| 204 | + statistic, not a staged input, so nothing can be parked exactly on 1.2 the |
| 205 | + way a valve command can be parked on 5%. The `small_oscillation` (±1.0 °C, |
| 206 | + `madShort` = 1.0) and `marginal_oscillation` (±1.35 °C, `madShort` = 1.35) |
| 207 | + vectors straddle the threshold from both sides. |
| 208 | +- **The reference's test vectors are statistical summaries, not tick traces** |
| 209 | + (stable 0.3/0.4 NO_FAULT; oscillating 2.5/0.4 FAULT; noisy-but-consistent |
| 210 | + 1.0/0.9 NO_FAULT). Each is re-expressed as a SAT trajectory that produces the |
| 211 | + stated scatter: `stable_operation`, `hunting_onset`, `noisy_but_consistent`. |
| 212 | + The noisy-but-consistent case is run at a larger amplitude (±3 °C rather than |
| 213 | + the reference's 1.0) so that the absolute test passes and the ratio test is |
| 214 | + the only thing blocking the fault — at the reference's own numbers both tests |
| 215 | + fail and the vector proves less. |
| 216 | +- Severity 3 (warning) and method `statistical`, per the reference's chapter 9 |
| 217 | + card — its §5.8.1 index carries no severity column, so nothing else in the |
| 218 | + reference speaks to severity. This chapter's README lists the fault as |
| 219 | + severity 4 / `rule`; the chapter 9 card governs and the index row needs |
| 220 | + correcting, as it did for AHU-FC-059. |
| 221 | +- The reference tags this fault for AHU and RTU; this card is the AHU-family |
| 222 | + instance, and an RTU-FC-056 would restate it against the RTU's discharge-air |
| 223 | + sensor. |
| 224 | +- `persist.delayOnInit = true` (Modelica/CDL default is `false`), the library's |
| 225 | + standing choice: a violation already present at load waits out the full 30 |
| 226 | + minutes instead of alarming on the first tick after a controller restart. |
| 227 | + |
| 228 | +## Notes |
| 229 | + |
| 230 | +**This detector flags onset, not steady state.** The ratio test normalizes |
| 231 | +against the unit's own recent history, so hunting that outlasts `long_window` |
| 232 | +raises `madLong` itself until `madShort > k × madLong` no longer holds, and the |
| 233 | +alarm clears with the loop still hunting. That is visible in `hunting_onset`: |
| 234 | +the oscillation starts at t = 9000 s, both conditions latch at 9450 s, the |
| 235 | +alarm asserts one `alarm_delay` later at 11250 s, and it releases at 12600 s — |
| 236 | +3600 s after onset, the point at which half the 2 h baseline window is itself |
| 237 | +oscillating and `madLong` has climbed past `madShort`/k. That release time is |
| 238 | +`long_window`/2 regardless of amplitude, since both statistics scale together. |
| 239 | +This is a property of the reference's own logic, not of the MAD substitution — |
| 240 | +its FAULT vector (short 2.5 against long 0.4) can only exist near onset. A host |
| 241 | +that wants a latched alarm holds the work order open after the first assert |
| 242 | +rather than tracking `yFault` continuously. |
| 243 | + |
| 244 | +The corollary is that a unit that has hunted for months reads as healthy until |
| 245 | +something disturbs it. Catching those needs a cross-unit or absolute-scatter |
| 246 | +comparison, which is a Phase 3 statistical rule, not this one. |
| 247 | + |
| 248 | +No playbook is referenced: nothing in `playbooks/` currently covers control-loop |
| 249 | +tuning. A loop-tuning playbook can adopt this fault when the VAV and |
| 250 | +control-tuning family lands. |
| 251 | + |
| 252 | +Diagnosis 4 deserves its precondition. A SAT transmitter with an intermittent |
| 253 | +connection produces textbook oscillation statistics with a perfectly tuned loop |
| 254 | +behind it, and the two are indistinguishable from this rule's output alone. |
| 255 | +Read the raw trend before touching tuning parameters: control hunting is |
| 256 | +smooth and roughly periodic, a failing sensor is neither. |
| 257 | + |
| 258 | +Related: AHU-FC-004 (excessive operating state changes per hour) is the same |
| 259 | +instability one layer up, at the sequencing logic rather than inside a single |
| 260 | +loop. A loop hunting hard enough to swing the unit between operating states |
| 261 | +trips both — AHU-FC-004 counts the state transitions, this rule measures the |
| 262 | +temperature scatter driving them. Hunting confined to one coil trips this rule |
| 263 | +alone. |
0 commit comments