Skip to content

Commit 9a09ba6

Browse files
jscott3201claude
andauthored
Add AHU fault batch 3: FC-056 (SAT hunting) and FC-063 (mode mismatch) (#8)
The last two AHU chapter-9 faults, verified against engine e2ff2f8 (16 faults total, all green): - AHU-FC-063 operating mode mismatch (CLU-01): first Integer boundary input. Consumes the host-derived expected_mode point; the library defines the 1/2/3 mode encoding (G36 s5.16 names the sequence but never numbers it) and exposes the three codes as params so hosts with their own enums rebind without editing the graph. Unmapped modes are structurally silent and declared NO_EVAL territory. - AHU-FC-056 SAT hunting (statistical): rolling std proxied by rolling mean absolute deviation (MovingAverage of |sat - MovingAverage|per window). The k-ratio test transfers unchanged; the absolute threshold is restated in MAD units (1.2 ~ 1.5 C std, both conversion factors documented). Card documents the engine's 64-checkpoint ring as a minimum-sample-interval constraint (tick >= long_window/64) and the detector's self-normalizing onset character. Harness: tools/verify json_to_value now coerces staged numbers by the destination point's declared value_type (Integer on Int points, Real on Real points) - vectors stay plain JSON. Smoke-tested end-to-end; all 14 pre-existing faults unchanged. Also: expected_mode added to the point dictionary (first int point, provisional); simultaneous-hc playbook Applies-to += AHU-FC-063; README rows corrected (FC-056 severity 4->3 and method rule-> statistical, FC-063 severity 2->3, per the reference's ch. 9 cards) and marked verified. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 461e567 commit 9a09ba6

12 files changed

Lines changed: 2698 additions & 26 deletions

File tree

faults/ahu/AHU-FC-056/card.md

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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+
![AHU-FC-056 block graph](diagram.svg)
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

Comments
 (0)