AHU-0022 — Supply air temperature hunting / oscillation
| Status | verified — engine e2ff2f8, cxf:fnv1a128:4a421aa419bd1f1fe626202c9cccf494, 2026-08-17 |
| Severity | 3 |
| Method | statistical |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0004, VAV-0005 |
| Playbooks | — |
| Source | HVAC FDD Reference v1.0 §9, AHU-0022; Research-backed; PID instability detection |
| Operating states | all (fan running) |
Preconditions (host-enforced): 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.
Points: sat
Outputs:
yFault— True while short-window SAT deviation has stayed above oscillation_threshold and above k × the long-window deviation for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
oscillation_threshold | 1.2 | °C | absHigh.t | 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 |
window | 900.0 | s | muShort.delta, madShort.delta | Short (scatter) window, 15 min — drives both the mean stage and the deviation-averaging stage; a host must move both paths together |
long_window | 7200.0 | s | muLong.delta, madLong.delta | Long (baseline) window, 2 h — drives both the mean stage and the deviation-averaging stage; a host must move both paths together |
k | 2.0 | ratio | scaledLong.k | Multiple of the long-window deviation the short window must exceed for scatter to count as a departure from the unit’s own baseline |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
Supply air temperature swings around its setpoint instead of settling on it. The signature is scatter, not offset: SAT may average exactly on setpoint while crossing it every few minutes. An oscillating loop keeps its valve or damper in continuous motion, wearing the actuator and burning coil energy on overshoot it then has to undo. The usual cause is proportional gain set too high, or integral time too short, for the coil’s actual authority — often after a valve or actuator was replaced with a differently sized part. The rule compares the unit against itself, since some AHUs simply run noisier than others: a fault requires recent scatter that is both absolutely large and several times the unit’s own baseline. Present in roughly 5% of buildings; severity 3, nothing here is unsafe.
Detection Logic
muShort = MovingAverage(sat, window) muLong = MovingAverage(sat, long_window)
madShort = MovingAverage(|sat − muShort|, window) madLong = MovingAverage(|sat − muLong|, long_window)
yFault = (madShort > oscillation_threshold) absolute scatter test
AND (madShort > k × madLong) onset test: scatter far above this unit's baseline
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Two identical chains run at two timescales. Each takes the moving average of
SAT over its window (muShort, muLong), subtracts it from the live reading,
takes the absolute value (errShort/devShort, errLong/devLong), and
averages that deviation over the same window again (madShort, madLong) — a
rolling mean absolute deviation, since the engine has no rolling standard
deviation (see Deviations for the conversion). absHigh applies the absolute
threshold to the short window; scaledLong and relHigh apply the ratio test.
Both comparisons are strict. persist requires 30 minutes of continuous
violation, which rides out one-off step disturbances — an economizer changeover
or a setpoint reset spikes madShort for about one short window and then
flushes out — and delayOnInit = true holds that window across a restart.
The ratio test normalizes against the unit’s own recent history, so this rule
flags onset, not steady state: hunting that outlasts long_window raises
madLong until madShort > k × madLong no longer holds, and the alarm clears
roughly long_window/2 after onset with the loop still hunting. That is a
property of the reference’s logic, not of the MAD substitution. Hosts should
hold the work order open after the first assert rather than tracking yFault.
Possible Diagnoses
- PID loop poorly tuned (oscillating) — gain too high or integral time too short for the coil’s authority
- Valve or damper actuator hunting — worn linkage, sticking stem, or a positioner fighting its own feedback
- Conflicting control loops — two sequences acting on the same air stream (e.g. a coil loop and a face-and-bypass or mixing loop with overlapping ranges)
- Intermittent sensor signal — a loose SAT wire or failing transmitter reads as oscillation with no control defect present
Energy Impact
COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no direct waste term to compute: an oscillating loop delivers roughly the right average temperature, and the loss is in the cycling itself — strokes that overshoot and correct, coil energy on excursions that cancel out, and the fan and pump work behind them. The reference puts this at 1–3% of AHU energy while the hunting lasts; confidence is LOW because no controlled study isolates oscillation losses from the tuning changes that fix them. Runtime estimation follows Energy Impact Reference §4.4 (hunting hours × AHU coil and fan power) — this rule contributes the hours, not the kilowatts. Climate-neutral.
Emissions Impact
Scope 2, QUALITATIVE_EMISSIONS, LOW confidence. Minimal in absolute terms — control-loop inefficiency, not a stuck-open coil. No avoided-emissions basis is published for this fault; a host that wants a number should apply its standard electricity factor to the fan and pump energy attributed above and treat the result as an order-of-magnitude estimate.
Deviations
- Rolling standard deviation → rolling mean absolute deviation. The reference
uses
rolling_std, and the engine’s elementary block set has no variance or standard-deviation block, so MAD is computed from fourMovingAverageinstances plus a subtract and an absolute value per timescale. MAD and std are proportional for any fixed waveform, so the ratio test carries over unchanged — the scale factor cancels on both sides ofmadShort > k × madLong. - The absolute threshold does not cancel, so it is restated in MAD units: for
Gaussian noise
std = 1.2533 × MAD, for a pure sinestd = 1.1107 × MAD, so the reference’s 1.5 °C std is a MAD of 1.20–1.35. The default takes the lower bound, 1.2; a site wanting the conservative end sets 1.35. Reals.MovingAverageis a continuous-time integral mean, not a sample mean — the engine accumulatesu·dtforward-Euler and divides by the window — so hand-computed sample statistics do not match it exactly.- Each
MovingAveragekeeps a fixed 64-checkpoint ring, so the tick interval must be ≥long_window/64= 112.5 s at the default windows. A host ticking faster silently shortens the baseline window: the fault still detects, but against a truncated baseline. - The reference’s “sufficient data in both windows” precondition is implemented
host-side. During the first
long_windowafter engine startmadLongdivides by elapsed time and underestimates the baseline, so the frontmatter precondition requires the host to report NO_EVAL for the first 2 h. - The short window is coarse at realistic tick rates — 900 s at a 300 s BAS tick is three samples — which the threshold and the 30-minute timer absorb. A faster trend interval smooths it at no cost, subject to the 112.5 s floor.
windowandlong_windoweach bind two CXF parameter paths (the mean stage and the deviation-averaging stage), likevalve_open_thresholdin AHU-0025. Hosts must set both paths of a window together; splitting them changes what the statistic means.- Both comparisons are strict (
>). The boundary is pinned by bracketing rather than an exact-equality tick, becausemadShortis a computed statistic and cannot be parked exactly on 1.2 the way a staged input can. - The reference states its test vectors as statistical summaries rather than tick traces; each is re-expressed here as a SAT trajectory producing the stated scatter. The noisy-but-consistent case runs at a larger amplitude than the reference’s, so the absolute test passes and the ratio test is the only thing blocking the fault — at the reference’s numbers both tests fail.
- Severity 3 (warning) and method
statisticalfollow the reference’s chapter 9 card. This chapter’s README lists severity 4 /rule; the chapter 9 card governs and the index row needs correcting, as it did for AHU-0025. - The reference tags this fault for AHU and RTU; this card is the AHU-family instance, and an RTU-family sibling would restate it against the RTU’s discharge-air sensor.
persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.
Notes
A unit that has hunted for months reads as healthy here until something disturbs it — catching those needs a cross-unit or absolute-scatter comparison, which is a Phase 3 rule, not this one.
Diagnosis 4 deserves its precondition: a SAT transmitter with an intermittent
connection produces textbook oscillation statistics behind a perfectly tuned
loop. Read the raw trend before touching tuning parameters — control hunting is
smooth and roughly periodic, a failing sensor is neither. No playbook is
referenced because nothing in playbooks/ yet covers control-loop tuning.
Test Vectors
7 scenarios, clock step 150 s over 21600 s.
| Scenario | Description |
|---|---|
stable_operation | Reference vector 1 (stable: short-window scatter 0.3, long-window 0.4). SAT holds 13 °C with a ±0.3 °C 15-min ripple for the whole 6 h horizon — normal PID trim, not hunting. madShort and madLong both settle at 0.3, so the absolute test (> 1.2) never fires and the ratio test never sees a raised short window. No fault at any tick. |
hunting_onset | Reference vector 2 (oscillating: short-window scatter 2.5 against a long-window 0.4). SAT is flat at 13 °C for 2.5 h — long enough for madLong to converge on ~0 across a full 2 h baseline — then a ±3 °C 15-min square wave starts at t=9000 s. madShort clears 1.2 at t=9450 s and both conditions hold; persist asserts 1800 s later at t=11250 s. The alarm then self-clears at t=12600 s: by then half the 2 h baseline window is itself oscillating, madLong has risen past madShort/2, and the ratio test releases. Asserted windows keep two 150 s steps of margin from both edges. |
noisy_but_consistent | Reference vector 3 (noisy but consistent: short 1.0 vs long 0.9 — scatter present in both windows). A ±3 °C ripple runs from t=0, so madShort and madLong converge on the same 3.0 and never separate by the factor k=2. The absolute test passes from t=450 s onward; the ratio test blocks the fault for the whole horizon. Established scatter is this detector’s baseline, not its alarm. |
small_oscillation | Threshold edge from below: same flat-then-oscillate trajectory as hunting_onset but at ±1.0 °C. The ratio test holds from t=9000 s (the baseline is near zero) yet madShort peaks at 1.083 and settles at 1.0, both under the 1.2 threshold, so the absolute test never fires. Pins that both conditions are required — a clean ratio alone cannot raise this fault. |
marginal_oscillation | Threshold edge from above: the same trajectory at ±1.35 °C, just over the 1.2 threshold. madShort crosses at t=9750 s (300 s later than the ±3 case, because the short window has to fill), persist asserts at t=11550 s, and the ratio test releases at t=12600 s as in hunting_onset. The pair small_oscillation / marginal_oscillation brackets oscillation_threshold from both sides. |
onset_stops_before_delay | Transient: a single 15-min hunting burst (±3 °C, t=9000–9900 s) on a flat baseline — one disturbance rejection, not sustained instability. Both conditions hold from t=9450 s but madShort decays back under 1.2 at t=10650 s, 1050 s of accumulated persistence into the 1800 s timer. The timer resets and no alarm is raised. |
mode_change_step | Transient: a one-time 5 °C SAT step at t=9000 s (economizer changeover or a setpoint reset), then steady operation at the new value. The step spikes madShort to 2.08 and both conditions hold from t=9150 s, but the short window flushes the step out and madShort falls back under 1.2 at t=10050 s — 750 s of persistence, well short of the 1800 s alarm delay. Pins that alarm_delay rides out a genuine step disturbance. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 150,
"horizon_s": 21600
},
"scenarios": [
{
"name": "stable_operation",
"description": "Reference vector 1 (stable: short-window scatter 0.3, long-window 0.4). SAT holds 13 \u00b0C with a \u00b10.3 \u00b0C 15-min ripple for the whole 6 h horizon \u2014 normal PID trim, not hunting. madShort and madLong both settle at 0.3, so the absolute test (> 1.2) never fires and the ratio test never sees a raised short window. No fault at any tick.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.3
},
{
"t": 450,
"value": 12.7
},
{
"t": 900,
"value": 13.3
},
{
"t": 1350,
"value": 12.7
},
{
"t": 1800,
"value": 13.3
},
{
"t": 2250,
"value": 12.7
},
{
"t": 2700,
"value": 13.3
},
{
"t": 3150,
"value": 12.7
},
{
"t": 3600,
"value": 13.3
},
{
"t": 4050,
"value": 12.7
},
{
"t": 4500,
"value": 13.3
},
{
"t": 4950,
"value": 12.7
},
{
"t": 5400,
"value": 13.3
},
{
"t": 5850,
"value": 12.7
},
{
"t": 6300,
"value": 13.3
},
{
"t": 6750,
"value": 12.7
},
{
"t": 7200,
"value": 13.3
},
{
"t": 7650,
"value": 12.7
},
{
"t": 8100,
"value": 13.3
},
{
"t": 8550,
"value": 12.7
},
{
"t": 9000,
"value": 13.3
},
{
"t": 9450,
"value": 12.7
},
{
"t": 9900,
"value": 13.3
},
{
"t": 10350,
"value": 12.7
},
{
"t": 10800,
"value": 13.3
},
{
"t": 11250,
"value": 12.7
},
{
"t": 11700,
"value": 13.3
},
{
"t": 12150,
"value": 12.7
},
{
"t": 12600,
"value": 13.3
},
{
"t": 13050,
"value": 12.7
},
{
"t": 13500,
"value": 13.3
},
{
"t": 13950,
"value": 12.7
},
{
"t": 14400,
"value": 13.3
},
{
"t": 14850,
"value": 12.7
},
{
"t": 15300,
"value": 13.3
},
{
"t": 15750,
"value": 12.7
},
{
"t": 16200,
"value": 13.3
},
{
"t": 16650,
"value": 12.7
},
{
"t": 17100,
"value": 13.3
},
{
"t": 17550,
"value": 12.7
},
{
"t": 18000,
"value": 13.3
},
{
"t": 18450,
"value": 12.7
},
{
"t": 18900,
"value": 13.3
},
{
"t": 19350,
"value": 12.7
},
{
"t": 19800,
"value": 13.3
},
{
"t": 20250,
"value": 12.7
},
{
"t": 20700,
"value": 13.3
},
{
"t": 21150,
"value": 12.7
},
{
"t": 21600,
"value": 13.3
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "hunting_onset",
"description": "Reference vector 2 (oscillating: short-window scatter 2.5 against a long-window 0.4). SAT is flat at 13 \u00b0C for 2.5 h \u2014 long enough for madLong to converge on ~0 across a full 2 h baseline \u2014 then a \u00b13 \u00b0C 15-min square wave starts at t=9000 s. madShort clears 1.2 at t=9450 s and both conditions hold; persist asserts 1800 s later at t=11250 s. The alarm then self-clears at t=12600 s: by then half the 2 h baseline window is itself oscillating, madLong has risen past madShort/2, and the ratio test releases. Asserted windows keep two 150 s steps of margin from both edges.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.0
},
{
"t": 9000,
"value": 16.0
},
{
"t": 9450,
"value": 10.0
},
{
"t": 9900,
"value": 16.0
},
{
"t": 10350,
"value": 10.0
},
{
"t": 10800,
"value": 16.0
},
{
"t": 11250,
"value": 10.0
},
{
"t": 11700,
"value": 16.0
},
{
"t": 12150,
"value": 10.0
},
{
"t": 12600,
"value": 16.0
},
{
"t": 13050,
"value": 10.0
},
{
"t": 13500,
"value": 16.0
},
{
"t": 13950,
"value": 10.0
},
{
"t": 14400,
"value": 16.0
},
{
"t": 14850,
"value": 10.0
},
{
"t": 15300,
"value": 16.0
},
{
"t": 15750,
"value": 10.0
},
{
"t": 16200,
"value": 16.0
},
{
"t": 16650,
"value": 10.0
},
{
"t": 17100,
"value": 16.0
},
{
"t": 17550,
"value": 10.0
},
{
"t": 18000,
"value": 16.0
},
{
"t": 18450,
"value": 10.0
},
{
"t": 18900,
"value": 16.0
},
{
"t": 19350,
"value": 10.0
},
{
"t": 19800,
"value": 16.0
},
{
"t": 20250,
"value": 10.0
},
{
"t": 20700,
"value": 16.0
},
{
"t": 21150,
"value": 10.0
},
{
"t": 21600,
"value": 16.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 10950,
"equals": false
},
{
"output": "yFault",
"from_s": 11550,
"to_s": 12150,
"equals": true
},
{
"output": "yFault",
"from_s": 12900,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "noisy_but_consistent",
"description": "Reference vector 3 (noisy but consistent: short 1.0 vs long 0.9 \u2014 scatter present in both windows). A \u00b13 \u00b0C ripple runs from t=0, so madShort and madLong converge on the same 3.0 and never separate by the factor k=2. The absolute test passes from t=450 s onward; the ratio test blocks the fault for the whole horizon. Established scatter is this detector's baseline, not its alarm.",
"inputs": {
"sat": [
{
"t": 0,
"value": 16.0
},
{
"t": 450,
"value": 10.0
},
{
"t": 900,
"value": 16.0
},
{
"t": 1350,
"value": 10.0
},
{
"t": 1800,
"value": 16.0
},
{
"t": 2250,
"value": 10.0
},
{
"t": 2700,
"value": 16.0
},
{
"t": 3150,
"value": 10.0
},
{
"t": 3600,
"value": 16.0
},
{
"t": 4050,
"value": 10.0
},
{
"t": 4500,
"value": 16.0
},
{
"t": 4950,
"value": 10.0
},
{
"t": 5400,
"value": 16.0
},
{
"t": 5850,
"value": 10.0
},
{
"t": 6300,
"value": 16.0
},
{
"t": 6750,
"value": 10.0
},
{
"t": 7200,
"value": 16.0
},
{
"t": 7650,
"value": 10.0
},
{
"t": 8100,
"value": 16.0
},
{
"t": 8550,
"value": 10.0
},
{
"t": 9000,
"value": 16.0
},
{
"t": 9450,
"value": 10.0
},
{
"t": 9900,
"value": 16.0
},
{
"t": 10350,
"value": 10.0
},
{
"t": 10800,
"value": 16.0
},
{
"t": 11250,
"value": 10.0
},
{
"t": 11700,
"value": 16.0
},
{
"t": 12150,
"value": 10.0
},
{
"t": 12600,
"value": 16.0
},
{
"t": 13050,
"value": 10.0
},
{
"t": 13500,
"value": 16.0
},
{
"t": 13950,
"value": 10.0
},
{
"t": 14400,
"value": 16.0
},
{
"t": 14850,
"value": 10.0
},
{
"t": 15300,
"value": 16.0
},
{
"t": 15750,
"value": 10.0
},
{
"t": 16200,
"value": 16.0
},
{
"t": 16650,
"value": 10.0
},
{
"t": 17100,
"value": 16.0
},
{
"t": 17550,
"value": 10.0
},
{
"t": 18000,
"value": 16.0
},
{
"t": 18450,
"value": 10.0
},
{
"t": 18900,
"value": 16.0
},
{
"t": 19350,
"value": 10.0
},
{
"t": 19800,
"value": 16.0
},
{
"t": 20250,
"value": 10.0
},
{
"t": 20700,
"value": 16.0
},
{
"t": 21150,
"value": 10.0
},
{
"t": 21600,
"value": 16.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "small_oscillation",
"description": "Threshold edge from below: same flat-then-oscillate trajectory as hunting_onset but at \u00b11.0 \u00b0C. The ratio test holds from t=9000 s (the baseline is near zero) yet madShort peaks at 1.083 and settles at 1.0, both under the 1.2 threshold, so the absolute test never fires. Pins that both conditions are required \u2014 a clean ratio alone cannot raise this fault.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.0
},
{
"t": 9000,
"value": 14.0
},
{
"t": 9450,
"value": 12.0
},
{
"t": 9900,
"value": 14.0
},
{
"t": 10350,
"value": 12.0
},
{
"t": 10800,
"value": 14.0
},
{
"t": 11250,
"value": 12.0
},
{
"t": 11700,
"value": 14.0
},
{
"t": 12150,
"value": 12.0
},
{
"t": 12600,
"value": 14.0
},
{
"t": 13050,
"value": 12.0
},
{
"t": 13500,
"value": 14.0
},
{
"t": 13950,
"value": 12.0
},
{
"t": 14400,
"value": 14.0
},
{
"t": 14850,
"value": 12.0
},
{
"t": 15300,
"value": 14.0
},
{
"t": 15750,
"value": 12.0
},
{
"t": 16200,
"value": 14.0
},
{
"t": 16650,
"value": 12.0
},
{
"t": 17100,
"value": 14.0
},
{
"t": 17550,
"value": 12.0
},
{
"t": 18000,
"value": 14.0
},
{
"t": 18450,
"value": 12.0
},
{
"t": 18900,
"value": 14.0
},
{
"t": 19350,
"value": 12.0
},
{
"t": 19800,
"value": 14.0
},
{
"t": 20250,
"value": 12.0
},
{
"t": 20700,
"value": 14.0
},
{
"t": 21150,
"value": 12.0
},
{
"t": 21600,
"value": 14.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "marginal_oscillation",
"description": "Threshold edge from above: the same trajectory at \u00b11.35 \u00b0C, just over the 1.2 threshold. madShort crosses at t=9750 s (300 s later than the \u00b13 case, because the short window has to fill), persist asserts at t=11550 s, and the ratio test releases at t=12600 s as in hunting_onset. The pair small_oscillation / marginal_oscillation brackets oscillation_threshold from both sides.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.0
},
{
"t": 9000,
"value": 14.35
},
{
"t": 9450,
"value": 11.65
},
{
"t": 9900,
"value": 14.35
},
{
"t": 10350,
"value": 11.65
},
{
"t": 10800,
"value": 14.35
},
{
"t": 11250,
"value": 11.65
},
{
"t": 11700,
"value": 14.35
},
{
"t": 12150,
"value": 11.65
},
{
"t": 12600,
"value": 14.35
},
{
"t": 13050,
"value": 11.65
},
{
"t": 13500,
"value": 14.35
},
{
"t": 13950,
"value": 11.65
},
{
"t": 14400,
"value": 14.35
},
{
"t": 14850,
"value": 11.65
},
{
"t": 15300,
"value": 14.35
},
{
"t": 15750,
"value": 11.65
},
{
"t": 16200,
"value": 14.35
},
{
"t": 16650,
"value": 11.65
},
{
"t": 17100,
"value": 14.35
},
{
"t": 17550,
"value": 11.65
},
{
"t": 18000,
"value": 14.35
},
{
"t": 18450,
"value": 11.65
},
{
"t": 18900,
"value": 14.35
},
{
"t": 19350,
"value": 11.65
},
{
"t": 19800,
"value": 14.35
},
{
"t": 20250,
"value": 11.65
},
{
"t": 20700,
"value": 14.35
},
{
"t": 21150,
"value": 11.65
},
{
"t": 21600,
"value": 14.35
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 11250,
"equals": false
},
{
"output": "yFault",
"from_s": 11850,
"to_s": 12150,
"equals": true
},
{
"output": "yFault",
"from_s": 12900,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "onset_stops_before_delay",
"description": "Transient: a single 15-min hunting burst (\u00b13 \u00b0C, t=9000\u20139900 s) on a flat baseline \u2014 one disturbance rejection, not sustained instability. Both conditions hold from t=9450 s but madShort decays back under 1.2 at t=10650 s, 1050 s of accumulated persistence into the 1800 s timer. The timer resets and no alarm is raised.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.0
},
{
"t": 9000,
"value": 16.0
},
{
"t": 9450,
"value": 10.0
},
{
"t": 9900,
"value": 13.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "mode_change_step",
"description": "Transient: a one-time 5 \u00b0C SAT step at t=9000 s (economizer changeover or a setpoint reset), then steady operation at the new value. The step spikes madShort to 2.08 and both conditions hold from t=9150 s, but the short window flushes the step out and madShort falls back under 1.2 at t=10050 s \u2014 750 s of persistence, well short of the 1800 s alarm delay. Pins that alarm_delay rides out a genuine step disturbance.",
"inputs": {
"sat": [
{
"t": 0,
"value": 13.0
},
{
"t": 9000,
"value": 8.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
}
]
}