Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AHU-0022 — Supply air temperature hunting / oscillation

Statusverified — engine e2ff2f8, cxf:fnv1a128:4a421aa419bd1f1fe626202c9cccf494, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0004, VAV-0005
Playbooks
SourceHVAC FDD Reference v1.0 §9, AHU-0022; Research-backed; PID instability detection
Operating statesall (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:

NameDefaultUnitCXF pathDescription
oscillation_threshold1.2°CabsHigh.tShort-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
window900.0smuShort.delta, madShort.deltaShort (scatter) window, 15 min — drives both the mean stage and the deviation-averaging stage; a host must move both paths together
long_window7200.0smuLong.delta, madLong.deltaLong (baseline) window, 2 h — drives both the mean stage and the deviation-averaging stage; a host must move both paths together
k2.0ratioscaledLong.kMultiple of the long-window deviation the short window must exceed for scatter to count as a departure from the unit’s own baseline
alarm_delay1800.0spersist.delayTimeContinuous 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):

AHU-0022 block graph

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

  1. PID loop poorly tuned (oscillating) — gain too high or integral time too short for the coil’s authority
  2. Valve or damper actuator hunting — worn linkage, sticking stem, or a positioner fighting its own feedback
  3. 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)
  4. 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 four MovingAverage instances 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 of madShort > 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 sine std = 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.MovingAverage is a continuous-time integral mean, not a sample mean — the engine accumulates u·dt forward-Euler and divides by the window — so hand-computed sample statistics do not match it exactly.
  • Each MovingAverage keeps 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_window after engine start madLong divides 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.
  • window and long_window each bind two CXF parameter paths (the mean stage and the deviation-averaging stage), like valve_open_threshold in 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, because madShort is 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 statistical follow 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 is false), 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.

ScenarioDescription
stable_operationReference 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_onsetReference 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_consistentReference 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_oscillationThreshold 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_oscillationThreshold 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_delayTransient: 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_stepTransient: 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
        }
      ]
    }
  ]
}