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

FCU-0001 — Excessive operating state changes

Statusverified — engine e2ff2f8, cxf:fnv1a128:db6929d05cee5a6cd064e2a6dcf3b1d3, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.22.6 FC#1
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0004, FCU-0006
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0001; G36 §5.22.6 FC#1; G36 §5.16.14 Table 5.16.14.7 (ΔOS MAX provenance, per Addendum u public review)
Operating statesOS 1–4 (all)

Preconditions (host-enforced): 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 unit whose state moved, across the whole building at once. count_scale must equal count_window divided by the host’s tick interval; the shipped 20.0 is right only on a 180 s tick, and a wrong value produces a plausible number rather than an error (see Deviations). The host tick must sit inside 57.15 s ≤ dt ≤ 450 s for the count to be both retained and reachable. Operator activity is not a fault: commissioning, manual mode forcing, and scheduled occupancy testing must be excluded host-side, since a deliberate mode change counts the same as an oscillation — this matters more on an FCU than on an AHU, because a guest or tenant at a wall thermostat produces exactly that signal. Warm-up evaluability is signalled in-rule by yWindowFull: while it is false the count is an extrapolated rate rather than a completed-hour count, and the verdict is NO_EVAL, not healthy. The same is true whenever any gate above is unmet.

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, counted over a window that has actually filled
  • yWindowFull — Evaluability signal — true once model time has reached count_window, which is the tick the moving average starts dividing by the window rather than by elapsed time; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
os_max7.01/hcntHigh.tTransitions per hour above which the unit counts as unstable rather than load-following (the chapter’s OS_MAX, and G36’s ΔOS MAX)
count_window3600.0srate.delta, windowFull.delayTimeTrailing window the transitions are counted over (1 h). It also fixes the units of os_max and the length of the warm-up gate, so both paths must move together; a host that shortens it must retune count_scale with it and read os_max as transitions per window rather than per hour
count_scale20.01count.kRescales the moving average of the one-tick pulse train back into a transition count: k = count_window / host tick interval in seconds. The default 20.0 is 3600/180, correct only at a 180 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay3600.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (the chapter’s AlarmDelay, 60 min)

Description

A fan coil unit that walks between heating, deadband, cooling and off more than a few times an hour is not following load. Load does not move that fast in a hotel room; what moves that fast is a threshold with nothing to hold the unit on one side of it — heating and cooling setpoints too close together, a zone sensor that jitters across the changeover point, or a wall thermostat someone keeps adjusting. Each transition strokes valves, restarts or respeeds the fan, and cools air that was being warmed a minute ago, so the energy goes into work that cancels itself. On one unit the numbers are small, which is exactly why the fault survives: a building with three hundred FCUs has some number oscillating right now and the only way to find them is to count. This is AHU-0004’s rule at zone scale, on faster equipment, which is why the tick band and the warm-up gate are stricter here.

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
yWindowFull  = model time ≥ count_window                             (false ⇒ host reports NO_EVAL)
yFault       = (count > os_max AND yWindowFull), sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0001 block graph

chg asks only whether the state value moved; its up and down outputs are declared for completeness and left unconnected, since which state the unit moved to says nothing about whether it is oscillating. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse encloses one tick interval of area and count_scale = count_window / dt converts the pulse average back into a transition count — making count_scale a property of the host’s clock rather than of the building, with a legal tick band at both ends (see Deviations). cntHigh is strict: exactly seven transitions an hour reads clear, eight alarms, and the arithmetic lands on the boundary exactly. The top branch is what AHU-0004 does not have: alwaysOnwindowFull goes true at exactly t = 3600 s, the instant the moving average stops dividing by elapsed time and starts dividing by the window. It gates the alarm and leaves the block as yWindowFull, so a host that reads it learns the difference between “not faulted” and “cannot tell yet”. The earliest possible assertion is therefore 7200 s: one hour to fill the window, one hour of sustained excess. Both delays carry delayOnInit = true.

Possible Diagnoses

  1. Deadband between heating and cooling too narrow — the condition that ends the heating mode is the condition that starts the cooling one, so the unit flips back as soon as it has finished acting
  2. Conflicting zone demands — a perimeter room with solar gain on one side and an exterior wall on the other asks for both, and the unit alternates
  3. Sensor noise causing mode oscillation — one poorly located or intermittent zone sensor crosses the changeover threshold every few minutes and the sequencing logic faithfully obeys

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no waste term to compute from this rule’s inputs — it sees a state index and cannot say what any transition cost. The reference puts the loss at 1–3% of zone energy, split between actuator wear and coils charged then abandoned before the air stream has settled. Size the opportunity host-side per Energy Impact Reference §4.4 (unstable hours × FCU coil and fan power); this rule contributes the hours. LOW confidence: no controlled study isolates cycling losses from the deadband change that fixes them, and no PNNL measure covers zone-level sequencing stability. Climate-neutral.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS, LOW confidence; on the order of 5–15 kg CO₂e/yr per FCU from cycling losses. Both scopes appear because the transitions cross between them — a unit oscillating between heating and cooling burns a little gas at the boiler and a little electricity at the chiller for the same hour of indecision. The magnitude is an order of magnitude, not an estimate; the reason to chase it is the fleet, where three hundred units at 10 kg CO₂e/yr is three tonnes against a setpoint change. Avoided-emissions basis: N/A.

Deviations

  • The reference card names no points; operating_state is our binding. The chapter 12 card states the logic and the tunables with no Required Points table, so this rule binds the host-derived integer point the FCU dictionary carries for the purpose. Only transitions are consumed and no value is ever interpreted, so any stable enumeration binds — the dictionary recommends G36 §5.22’s OS#1–#4 index and requires only that the encoding not change.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-hour count would need a host-driven hourly reset — a tumbling count whose verdict depends on where the hour boundary fell.
  • count_scale is coupled to the host’s tick interval, and the default is not AHU-0004’s. k = count_window / dt, and 20.0 is correct only at a 180 s tick; AHU-0004 ships 12.0 for a 300 s tick, and copying a count_scale between the two cards is the most likely way to deploy this wrong. The failure is silent: an FCU changing state 20 times an hour, read on a 60 s tick with count_scale left at 20.0, reports a steady 6.7 per hour and never alarms.
  • The legal tick band is 57.15 s ≤ dt ≤ 450 s, and both ends bite. The lower end is the MovingAverage ring — 64 checkpoints, of which a window retains count_window/dt + 1, so dt ≥ 3600/63; past it the block silently drops the oldest in-window sample, shortening the window and inflating the count. The upper end is the change counter: at most one transition per tick, so a strict count > 7 needs dt ≤ 3600/8 = 450 s for 8 to be reachable.
  • AHU-0004 states the ring bound as 56.25 s (3600/64); the correct figure is 57.15 s (3600/63). The retained set includes one checkpoint at or before the window’s trailing edge, so a window spanning n ticks needs n + 1 slots. The difference matters only within a second of the bound.
  • A change counter aliases, and the count is clipped rather than wrong-signed. Above the ceiling the rule under-reports: at the shipped 180 s tick a unit changing state every 90 s shows at most 20/h, and a state that returns to its previous value within one tick shows nothing. Clipping delays nothing, since the alarm path is limited by the window and the delay rather than by how far above os_max the count sits, but a host that displays the count should say it is a floor.
  • Startup artifact (a): a spurious first-tick pulse, which costs nothing. Integers.Change compares against pre_u_start on the first tick, so a unit that loads in OS#3 registers a change at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count. chg.pre_u_start is written explicitly as 0 and is not a card parameter.
  • Startup artifact (b): the first hour reads as a rate, and here the graph handles it rather than the host. While t < count_window the moving average divides by elapsed time, so two changes in the first six minutes read as 20/h. AHU-0004 leaves that to a precondition; this card computes the condition in the graph because it is computable, and gate keeps the extrapolated rate off the persistence clock until the window has filled.
  • The window gate makes this rule slower than AHU-0004, deliberately. Earliest assertion is count_window + alarm_delay = 7200 s, where AHU-0004 can assert at 3900 s on a first-hour extrapolation its frontmatter then tells the host to discard. Requiring a completed hour is what “transitions per hour” actually says, at an hour of detection latency on a fault whose alarm delay is already an hour.
  • BooleanToIntegerIntegerToReal where Conversions.BooleanToReal would do it in one block. Kept for shape parity with AHU-0004, so the two cards read as the same rule; it costs one block instance and no behavior.
  • Strict > on a discrete count. Exactly 7 transitions an hour is clear and 8 alarms — the chapter’s > OS_MAX read literally — and the arithmetic is exact at the boundary rather than approximately exact.
  • The counting window is half-open. A transition exactly count_window old has just left the window. The reference is silent; it matters only on the threshold and errs toward silence.
  • alarm_delay equals count_window, both 60 min per the chapter’s tunables line. This is not G36’s 30-minute AlarmDelay; the chapter governs.
  • alwaysOn.k is written explicitly and is not a card parameter. Logical.Sources.Constant has no default for k, and exposing it would let a host switch the evaluability signal off, which is not a tuning decision.
  • The reference publishes no vectors, so the whole suite is authored from the equation, and every assertion edge was derived by replaying the graph at the pinned engine rev — the moving average’s warm-up and decay trajectories do not match hand-computed sample statistics.
  • CLU-01 membership. A zone unit swapping between heating and cooling every few minutes is doing both within any window long enough to matter, so AHU-0004’s syndrome argument carries over. The cluster index owner accepted the case and clusters/clusters.json lists FCU-0001 in CLU-01.
  • persist.delayOnInit = true and windowFull.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice. windowFull depends on it for its meaning — at false the constant-true input would assert on tick 0 and the signal would say the window was full when it was empty.
  • related is the library’s, not the reference’s. The chapter 12 card carries no Related row; AHU-0004 is named as the same rule one level up the air path, one-way because that card’s frontmatter is not edited here. No intra-FCU link is claimed.
  • Severity 3 (warning) and method rule per the reference’s chapter 12 card and the FCU index; its §5.8.5 index carries no severity column. Operating states OS 1–4 are declared, not gated — the chapter marks the fault applicable in every state and there is nothing for the graph to exclude.

Notes

The fix is a deadband, and it is remote and free. Step 2.1 of the fcu-faults playbook puts the minimum at 2 °F (1 °C) between heating and cooling setpoints and says to check for sensor noise first, which is the right order: widening a deadband around a jittery input hides the noise without fixing it. Step 4.2 confirms at transitions back under 7/h, which is this rule reading clear.

The reason to run this rule is fleet triage — the list of which forty units out of three hundred are oscillating is worth an afternoon, because they will almost all share one cause. Sort by count, not by alarm: the number behind the boolean (count.y) is what ranks the work. count_scale and the tick belong on any deployment checklist; they are the only values here that are properties of the host’s clock rather than of the building, and the failure mode is silent.

Test Vectors

9 scenarios, clock step 180 s over 21600 s.

ScenarioDescription
stable_stateThe 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. yWindowFull is pinned on both sides of its only edge: false through 3420 s, true from exactly 3600 s, which is the tick the moving average’s divisor becomes count_window rather than elapsed time
startup_pulse_is_inertoperating_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_hourThe unit walks OS 1-2-3-4-1 every 900 s: four transitions an hour, ordinary for a zone tracking load. count holds at exactly 4.0 once the window has filled and yFault never rises
seven_per_hour_stays_clearBoundary, low side. Seven transitions on consecutive ticks at the top of every hour, so every completed trailing hour holds exactly seven. count lands on exactly 7.0 and os_max is a strict ‘>’, so seven an hour reads clear forever. The first hour reads 20.0 - the extrapolated pace of a seven-tick burst - and yWindowFull is false there, which is precisely the case the window gate exists to swallow
eight_per_hour_tripsBoundary, high side. The same shape with eight transitions an hour: count sits on exactly 8.0 from the first completed window. gate rises at 3600 s (the tick yWindowFull matures, cntHigh already being true) and yFault follows one alarm_delay later at exactly 7200 s - the earliest instant this rule can assert anything
sustained_thrash_at_nyquist_ceilingoperating_state alternates on every tick - 20 transitions an hour at the 180 s tick, which is exactly the change-counter ceiling count_window/dt. count reads 20.0 and cannot read higher however fast the real unit cycles; the rule still trips at 7200 s, the same instant as the eight-per-hour case, because the alarm path is limited by the window and the delay rather than by how far above os_max the count sits
warmup_burst_masked_by_window_gateStartup artifact: nine transitions inside the first half hour read as a 20/hr pace while the moving average is still dividing by elapsed time. cntHigh is true from the second tick, but yWindowFull is false until 3600 s so gate stays down. At 3600 s the completed window genuinely holds nine transitions and gate rises - then the burst ages out and the count falls to 7.0 by 3960 s, so gate was true at two ticks out of the 3600 s alarm_delay and nothing is ever reported
thrash_stops_before_delayTransient: fourteen transitions from 3600 s to 5940 s, then the unit settles. count crosses os_max at 4860 s and decays back through 7.0 at 8280 s as the pulses leave the trailing hour, so gate held from 4860 s to 8100 s - 3240 s, ninety percent of alarm_delay - and yFault never rises. This is the vector that fails first if persist is given an off-delay or if the count is made to decay any slower
burst_ages_out_of_windowA two-hour thrash (3600 s to 10800 s) is long enough: gate rises at 4860 s and yFault asserts at 8460 s. When the thrash stops the count does not drop at once - it decays as pulses leave the trailing hour, reaching 7.0 at 13140 s, and the alarm clears on that tick because TrueDelay has no off-delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 180,
    "horizon_s": 21600
  },
  "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. yWindowFull is pinned on both sides of its only edge: false through 3420 s, true from exactly 3600 s, which is the tick the moving average's divisor becomes count_window rather than elapsed time",
      "inputs": {
        "operating_state": 0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "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",
      "inputs": {
        "operating_state": 3
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "four_transitions_per_hour",
      "description": "The unit walks OS 1-2-3-4-1 every 900 s: four transitions an hour, ordinary for a zone tracking load. count holds at exactly 4.0 once the window has filled and yFault never rises",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1800,
            "value": 3
          },
          {
            "t": 2700,
            "value": 4
          },
          {
            "t": 3600,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 5400,
            "value": 3
          },
          {
            "t": 6300,
            "value": 4
          },
          {
            "t": 7200,
            "value": 1
          },
          {
            "t": 8100,
            "value": 2
          },
          {
            "t": 9000,
            "value": 3
          },
          {
            "t": 9900,
            "value": 4
          },
          {
            "t": 10800,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 12600,
            "value": 3
          },
          {
            "t": 13500,
            "value": 4
          },
          {
            "t": 14400,
            "value": 1
          },
          {
            "t": 15300,
            "value": 2
          },
          {
            "t": 16200,
            "value": 3
          },
          {
            "t": 17100,
            "value": 4
          },
          {
            "t": 18000,
            "value": 1
          },
          {
            "t": 18900,
            "value": 2
          },
          {
            "t": 19800,
            "value": 3
          },
          {
            "t": 20700,
            "value": 4
          },
          {
            "t": 21600,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "seven_per_hour_stays_clear",
      "description": "Boundary, low side. Seven transitions on consecutive ticks at the top of every hour, so every completed trailing hour holds exactly seven. count lands on exactly 7.0 and os_max is a strict '>', so seven an hour reads clear forever. The first hour reads 20.0 - the extrapolated pace of a seven-tick burst - and yWindowFull is false there, which is precisely the case the window gate exists to swallow",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 3600,
            "value": 1
          },
          {
            "t": 3780,
            "value": 2
          },
          {
            "t": 3960,
            "value": 1
          },
          {
            "t": 4140,
            "value": 2
          },
          {
            "t": 4320,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4680,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 10800,
            "value": 1
          },
          {
            "t": 10980,
            "value": 2
          },
          {
            "t": 11160,
            "value": 1
          },
          {
            "t": 11340,
            "value": 2
          },
          {
            "t": 11520,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 11880,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 18000,
            "value": 1
          },
          {
            "t": 18180,
            "value": 2
          },
          {
            "t": 18360,
            "value": 1
          },
          {
            "t": 18540,
            "value": 2
          },
          {
            "t": 18720,
            "value": 1
          },
          {
            "t": 18900,
            "value": 2
          },
          {
            "t": 19080,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "eight_per_hour_trips",
      "description": "Boundary, high side. The same shape with eight transitions an hour: count sits on exactly 8.0 from the first completed window. gate rises at 3600 s (the tick yWindowFull matures, cntHigh already being true) and yFault follows one alarm_delay later at exactly 7200 s - the earliest instant this rule can assert anything",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 1260,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          },
          {
            "t": 10980,
            "value": 1
          },
          {
            "t": 11160,
            "value": 2
          },
          {
            "t": 11340,
            "value": 1
          },
          {
            "t": 11520,
            "value": 2
          },
          {
            "t": 11700,
            "value": 1
          },
          {
            "t": 11880,
            "value": 2
          },
          {
            "t": 12060,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 15660,
            "value": 1
          },
          {
            "t": 18000,
            "value": 2
          },
          {
            "t": 18180,
            "value": 1
          },
          {
            "t": 18360,
            "value": 2
          },
          {
            "t": 18540,
            "value": 1
          },
          {
            "t": 18720,
            "value": 2
          },
          {
            "t": 18900,
            "value": 1
          },
          {
            "t": 19080,
            "value": 2
          },
          {
            "t": 19260,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_thrash_at_nyquist_ceiling",
      "description": "operating_state alternates on every tick - 20 transitions an hour at the 180 s tick, which is exactly the change-counter ceiling count_window/dt. count reads 20.0 and cannot read higher however fast the real unit cycles; the rule still trips at 7200 s, the same instant as the eight-per-hour case, because the alarm path is limited by the window and the delay rather than by how far above os_max the count sits",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 1260,
            "value": 1
          },
          {
            "t": 1440,
            "value": 2
          },
          {
            "t": 1620,
            "value": 1
          },
          {
            "t": 1800,
            "value": 2
          },
          {
            "t": 1980,
            "value": 1
          },
          {
            "t": 2160,
            "value": 2
          },
          {
            "t": 2340,
            "value": 1
          },
          {
            "t": 2520,
            "value": 2
          },
          {
            "t": 2700,
            "value": 1
          },
          {
            "t": 2880,
            "value": 2
          },
          {
            "t": 3060,
            "value": 1
          },
          {
            "t": 3240,
            "value": 2
          },
          {
            "t": 3420,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          },
          {
            "t": 6120,
            "value": 2
          },
          {
            "t": 6300,
            "value": 1
          },
          {
            "t": 6480,
            "value": 2
          },
          {
            "t": 6660,
            "value": 1
          },
          {
            "t": 6840,
            "value": 2
          },
          {
            "t": 7020,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 8640,
            "value": 2
          },
          {
            "t": 8820,
            "value": 1
          },
          {
            "t": 9000,
            "value": 2
          },
          {
            "t": 9180,
            "value": 1
          },
          {
            "t": 9360,
            "value": 2
          },
          {
            "t": 9540,
            "value": 1
          },
          {
            "t": 9720,
            "value": 2
          },
          {
            "t": 9900,
            "value": 1
          },
          {
            "t": 10080,
            "value": 2
          },
          {
            "t": 10260,
            "value": 1
          },
          {
            "t": 10440,
            "value": 2
          },
          {
            "t": 10620,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          },
          {
            "t": 10980,
            "value": 1
          },
          {
            "t": 11160,
            "value": 2
          },
          {
            "t": 11340,
            "value": 1
          },
          {
            "t": 11520,
            "value": 2
          },
          {
            "t": 11700,
            "value": 1
          },
          {
            "t": 11880,
            "value": 2
          },
          {
            "t": 12060,
            "value": 1
          },
          {
            "t": 12240,
            "value": 2
          },
          {
            "t": 12420,
            "value": 1
          },
          {
            "t": 12600,
            "value": 2
          },
          {
            "t": 12780,
            "value": 1
          },
          {
            "t": 12960,
            "value": 2
          },
          {
            "t": 13140,
            "value": 1
          },
          {
            "t": 13320,
            "value": 2
          },
          {
            "t": 13500,
            "value": 1
          },
          {
            "t": 13680,
            "value": 2
          },
          {
            "t": 13860,
            "value": 1
          },
          {
            "t": 14040,
            "value": 2
          },
          {
            "t": 14220,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 15660,
            "value": 1
          },
          {
            "t": 15840,
            "value": 2
          },
          {
            "t": 16020,
            "value": 1
          },
          {
            "t": 16200,
            "value": 2
          },
          {
            "t": 16380,
            "value": 1
          },
          {
            "t": 16560,
            "value": 2
          },
          {
            "t": 16740,
            "value": 1
          },
          {
            "t": 16920,
            "value": 2
          },
          {
            "t": 17100,
            "value": 1
          },
          {
            "t": 17280,
            "value": 2
          },
          {
            "t": 17460,
            "value": 1
          },
          {
            "t": 17640,
            "value": 2
          },
          {
            "t": 17820,
            "value": 1
          },
          {
            "t": 18000,
            "value": 2
          },
          {
            "t": 18180,
            "value": 1
          },
          {
            "t": 18360,
            "value": 2
          },
          {
            "t": 18540,
            "value": 1
          },
          {
            "t": 18720,
            "value": 2
          },
          {
            "t": 18900,
            "value": 1
          },
          {
            "t": 19080,
            "value": 2
          },
          {
            "t": 19260,
            "value": 1
          },
          {
            "t": 19440,
            "value": 2
          },
          {
            "t": 19620,
            "value": 1
          },
          {
            "t": 19800,
            "value": 2
          },
          {
            "t": 19980,
            "value": 1
          },
          {
            "t": 20160,
            "value": 2
          },
          {
            "t": 20340,
            "value": 1
          },
          {
            "t": 20520,
            "value": 2
          },
          {
            "t": 20700,
            "value": 1
          },
          {
            "t": 20880,
            "value": 2
          },
          {
            "t": 21060,
            "value": 1
          },
          {
            "t": 21240,
            "value": 2
          },
          {
            "t": 21420,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_burst_masked_by_window_gate",
      "description": "Startup artifact: nine transitions inside the first half hour read as a 20/hr pace while the moving average is still dividing by elapsed time. cntHigh is true from the second tick, but yWindowFull is false until 3600 s so gate stays down. At 3600 s the completed window genuinely holds nine transitions and gate rises - then the burst ages out and the count falls to 7.0 by 3960 s, so gate was true at two ticks out of the 3600 s alarm_delay and nothing is ever reported",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 180,
            "value": 2
          },
          {
            "t": 360,
            "value": 1
          },
          {
            "t": 540,
            "value": 2
          },
          {
            "t": 720,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1080,
            "value": 1
          },
          {
            "t": 1260,
            "value": 2
          },
          {
            "t": 1440,
            "value": 1
          },
          {
            "t": 1620,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "thrash_stops_before_delay",
      "description": "Transient: fourteen transitions from 3600 s to 5940 s, then the unit settles. count crosses os_max at 4860 s and decays back through 7.0 at 8280 s as the pulses leave the trailing hour, so gate held from 4860 s to 8100 s - 3240 s, ninety percent of alarm_delay - and yFault never rises. This is the vector that fails first if persist is given an off-delay or if the count is made to decay any slower",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "burst_ages_out_of_window",
      "description": "A two-hour thrash (3600 s to 10800 s) is long enough: gate rises at 4860 s and yFault asserts at 8460 s. When the thrash stops the count does not drop at once - it decays as pulses leave the trailing hour, reaching 7.0 at 13140 s, and the alarm clears on that tick because TrueDelay has no off-delay",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          },
          {
            "t": 6120,
            "value": 2
          },
          {
            "t": 6300,
            "value": 1
          },
          {
            "t": 6480,
            "value": 2
          },
          {
            "t": 6660,
            "value": 1
          },
          {
            "t": 6840,
            "value": 2
          },
          {
            "t": 7020,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 8640,
            "value": 2
          },
          {
            "t": 8820,
            "value": 1
          },
          {
            "t": 9000,
            "value": 2
          },
          {
            "t": 9180,
            "value": 1
          },
          {
            "t": 9360,
            "value": 2
          },
          {
            "t": 9540,
            "value": 1
          },
          {
            "t": 9720,
            "value": 2
          },
          {
            "t": 9900,
            "value": 1
          },
          {
            "t": 10080,
            "value": 2
          },
          {
            "t": 10260,
            "value": 1
          },
          {
            "t": 10440,
            "value": 2
          },
          {
            "t": 10620,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8280,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8640,
          "to_s": 12780,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 13320,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}