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

HW-0001 — Boiler short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:504010d1861bbe0d8a10b7f90528e0b1, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0002, HW-0003, HW-0011, HW-0012, RTU-0001, AHU-0004
Playbookshot-water-plant-faults
SourceHVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 124-125), HW-0001; Shohet et al. 2020; Meng et al. 2021
Operating statesheating season / HW plant enabled

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start. While the moving average’s window fills, its divisor is elapsed time rather than the window, so the count reports an extrapolated pace instead of a completed-hour tally, and alarm_delay (15 min) is far too short to cover the hour on its own — warmup_pace_asserts_on_two_starts shows a verdict reached at 1200 s on the strength of two starts. The plant must be enabled and in heating season: a boiler idle because nothing is calling for heat produces zero starts, and reporting that as healthy cycling is the opposite of information. boiler_status must be the burner’s FIRING (flame) status, not the boiler’s enable status — an enable that stays true across an entire morning hides every burner cycle inside it, which is the point dictionary’s warning on this point. Bind it per boiler: on a multi-boiler plant the OR of the statuses never falls while any boiler is firing, so every lag-boiler start is invisible and each boiler’s own cycling is undercounted. The host tick interval must sit inside the band the count arithmetic and the edge counter jointly allow — 57.15 s ≤ dt < 360 s — with count_scale retuned to match (see Deviations). When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: boiler_status

Outputs:

  • yFault — True while the number of boiler starts in the trailing count_window has stayed above max_starts_per_hour for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_starts_per_hour4.01/hcntHigh.tStarts per hour above which the cycling counts as short-cycling rather than load-following. The reference’s ceiling, equivalent to a 15-minute minimum interval between firings
count_window3600.0srate.deltaTrailing window the starts are counted over (1 h). It also fixes the units of max_starts_per_hour; a host that shortens it must retune count_scale with it and read the ceiling as starts per window rather than per hour
count_scale12.01count.kRescales the moving average of the one-tick pulse train back into a start count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

Every boiler start pays twice: a pre-purge that pushes the previous cycle’s heat up the stack, a burner run through the part of its range where the fuel/air ratio is worst, and a post-purge that does it again. The vessel takes a thermal step with each one — fire side at flame temperature in seconds, water side lagging — and the differential works the tube sheet, refractory and welds. The reference puts the efficiency penalty at 3–5% and the avoided damage at $10K–$100K, which is the argument for severity 2 on a fault whose energy number is modest. None of the causes is visible from the water temperature, which is why this shows up as a start count rather than a comfort complaint; the rule reports that the burner starts more often than the plant should need, and the service call decides why.

Detection Logic

start  = rising edge of boiler_status                      one tick wide
count  = MovingAverage(start, count_window) × count_scale   starts in the trailing hour
yFault = count > max_starts_per_hour, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0001 block graph

Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses exactly one tick interval of area: n starts inside the window give rate = n · dt / count_window, and count_scale = count_window / dt recovers n exactly (four starts an hour at a 300 s tick is precisely 4.0 in IEEE-754). count_scale must be retuned to the host tick — left at 12.0 on a 60 s tick the count reads a fifth of the true cadence and the rule never fires, and a mis-set scale gives a plausible number rather than an error. The legal tick band is 57.15 s ≤ dt < 360 s (Deviations); 300 s is recommended and is the only tick these vectors exercise.

cntHigh is strict, so four starts an hour reads clear and five alarms; persist then requires 15 minutes above the ceiling and carries delayOnInit = true. The count is rolling, not tumbling: a crossing survives count_window minus the span of the starts that caused it, so the same five starts in one hour alarm or not depending on how tightly they are packed, and the alarm outlives the cycling by up to a full hour. During the first count_window the average divides by elapsed time rather than by the window and reports an extrapolated pace that alarm_delay is too short to cover — the host NO_EVAL precondition for that hour is load-bearing.

Possible Diagnoses

Transcribed from the reference’s HW-0001 card:

  1. Boiler oversized for the current load — the fix is staging, a lead boiler with more turndown, or a buffer tank
  2. Aquastat differential too small, so the burner satisfies and relights a minute later. Check this first: it is free and remote
  3. Staging logic cycling between boilers, which no single boiler’s own controls would cause — the reason this rule is bound per boiler
  4. Short-circuiting in the piping — primary/secondary imbalance returning supply water to the boiler inlet, which reads as load satisfaction
  5. Control valve hunting downstream, modulating plant load faster than the boiler can follow (AHU-0004 is the AHU-side view of the same instability)

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so there is no waste term computable from its inputs. Size the opportunity host-side: cycling hours × rated fuel input × the 3–5% efficiency penalty (Energy Impact Reference §4.4), with the $10K–$100K of avoided boiler damage as the larger and more probabilistic term. Confidence is MEDIUM because the efficiency figure depends on cycle length, return water temperature and how far the vessel gets from steady state, none of which this rule measures. Heating-dominant: the plant only runs in the heating season.

Emissions Impact

Scope 1, QUALITATIVE_EMISSIONS, MEDIUM confidence. This is combustion at the building, so the 3–5% loss is fuel burned on site rather than purchased electricity whatever the grid is doing. The reference records the range as “protective; indirect via boiler degradation” and sets the avoided-emissions basis to N/A, which this card keeps — the larger term is the embodied carbon of a pressure vessel replaced years early.

Deviations

  • min_run_time is a tunable, not a graph parameter. The reference lists it at 10 min beside max_starts_per_hour and then prints one line of logic, never saying how a per-cycle duration enters the verdict. Recorded as a transcription gap rather than filled in with invented logic; the starts-per-hour ceiling subsumes the protective intent, and RTU-0001 documents the identical gap.
  • The rolling count is built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing hour would need a host-driven reset — a tumbling window whose verdict depends on where the boundary falls. AHU-0004 established the idiom and RTU-0001 carried it to a compressor.
  • count_scale = count_window / dt couples the rule to the host tick, and the failure is silent. A host ticking every 60 s must set 60.0; too low and the rule never fires, too high and it alarms on a healthy boiler. It belongs on the deployment checklist beside the point binding.
  • The legal tick band is 57.15 s ≤ dt < 360 s. The floor is Reals.MovingAverage’s fixed 64-checkpoint ring: dt ≥ count_window / 63. The ceiling is the edge counter — a rising edge needs a false sample between two true ones, and observing the first faulted cadence (5/h) needs 5 ≤ count_window / (2 · dt), i.e. dt ≤ 360 s; 360 s itself is excluded because at one half-period per tick detection depends on sampling phase. RTU-0001’s looser dt < 450 s only makes the threshold exceedable at all, and misses the ordinary 5/h boiler.
  • Nyquist is necessary, not sufficient: the constraint is the shorter of the two intervals. The honest binding rule is dt ≤ min(shortest firing, shortest off period), which for a boiler with a 10-minute minimum run and a tight aquastat is the off period. At the 300 s tick a boiler that restarts within 300 s of shutting down has starts swallowed and the count reads low. The failure direction is silence — right for a protective rule, worth knowing when a technician insists the boiler is cycling.
  • The first count_window reads as a pace, not a count, and alarm_delay does not cover it. While t < count_window the average divides by elapsed time, so two starts in the first ten minutes report an extrapolated rate and persist matures on it (engine-pinned: yFault at 1200 s, clearing at 1800 s as the divisor grows). AHU-0004 is immune because its alarm_delay equals its count_window; this rule, like RTU-0001, is not, which is why the first-hour NO_EVAL precondition is load-bearing.
  • The startup edge pulse is spurious and inert. Logical.Edge compares u against pre_u_start on the first tick, so a boiler already firing when the rule loads registers a start at t = 0. It never reaches the count: dt is zero on that tick, so the pulse encloses no area. pre_u_start is left at the CDL default per SCHEMA.md’s “set only non-default values”.
  • Strict > on a discrete count, as the reference writes it: four starts an hour is clear and five alarms. The boundary is unambiguous rather than measure-zero, because the recovered count is exact in steady state — 12.0 × (4 × 300/3600) evaluates to precisely 4.0.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a start exactly that old has just left the window. It errs toward silence, and it is what makes the alarm outlive the cycling: the lag is count_window minus the span of the last five starts, a full hour when the boiler stops after a single burst.
  • TrueDelay asserts at exactly T + delayTime, so a count that falls back on the same tick the timer matures reports nothing. The realized test is “above the ceiling for strictly more than alarm_delay”, read at tick resolution; five starts spread over 45 minutes rather than 40 is the whole difference between an alarm and silence.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a plant already cycling above the ceiling when the controller restarts waits out the full 15 minutes rather than alarming on the first tick.
  • Operating state is declared, not gated. “Heating season / HW plant enabled” is the reference’s own operating-state line; there is no plant-enable point in the equation to gate on, and inventing one would move the rule’s boundary.
  • clusters: []. clusters/clusters.json defines no cluster containing a hot water plant rule, and this card does not edit the cluster set. CLU-07 (Unnecessary Plant Operation) is where HW-0003 would belong; this fault is not part of that syndrome.
  • No test vectors are transcribed, because the reference publishes none. Every scenario in vectors.json is authored from the equation, and each assertion edge was derived by replaying the graph at the pinned engine rev rather than by closed-form arithmetic — the moving average’s warm-up and decay trajectories do not match hand-computed sample statistics.
  • Severity 2, phase 2, method: rule and the tunable defaults are the reference’s chapter 14 card. g36: null — this is a research-derived rule (Shohet et al. 2020; Meng et al. 2021), not a G36 clause.

Notes

Bind boiler_status to the burner’s flame status, and bind it per boiler: an enable that stays true all morning reports zero starts, and an OR across a multi-boiler plant never falls while any boiler fires, so every lag-boiler start is invisible.

Check diagnosis 2 remotely before anyone drives out — pull supply water temperature alongside boiler_status, and a burner shutting down within a degree or two of setpoint and relighting immediately is the aquastat differential, free to widen. HW-0002 reads the same boiler from the efficiency side: a plant tripping both may have one problem, while HW-0002 alone with a steady fire is a combustion or heat-transfer finding.

Test Vectors

11 scenarios, clock step 300 s over 14400 s.

ScenarioDescription
boiler_off_all_dayThe boiler never fires. No rising edges, no count, no verdict — the baseline case that shows the counter reads zero rather than drifting.
steady_firing_never_countsThe boiler is firing continuously from the moment the rule loads and never stops. Logical.Edge registers one rising edge at t = 0 against its pre_u_start default, and that pulse encloses no area because the moving average’s first tick has dt = 0, so the count stays at zero. A boiler that holds fire for four hours is the opposite of short-cycling and reads as such.
three_starts_per_hourHealthy load-following: a 5-minute fire every 20 minutes, three starts an hour against a ceiling of four. The cadence begins at t = 3600 s so the reading is a completed-hour count rather than the warm-up extrapolation.
four_starts_per_hour_is_exactly_the_thresholdThreshold edge from below: a 5-minute fire every 15 minutes puts exactly four starts in every trailing hour. The count evaluates to precisely 4.0 in IEEE-754 (12.0 x 1200/3600), so the strict > keeps it clear — this is a real boundary pin, not a near miss.
five_starts_per_hour_is_the_first_faulted_cadenceThreshold edge from above, and the finest cadence a 300 s tick can resolve above four: a repeating 600/600/600/900/900 s spacing puts exactly five rising edges in every trailing hour. The count clears 4 at t = 6300 s (the fifth start) and the alarm follows one alarm_delay later at 7200 s.
six_starts_per_hour_is_the_fastest_resolvable_cadenceA boiler cycling on a 10-minute period — five minutes of fire, five minutes off — which at the 300 s tick these vectors run is the Nyquist ceiling: one start per two ticks, 6/h. The count crosses 4 at t = 6000 s and the alarm lands at 6900 s.
five_starts_in_forty_minutes_alarms_for_one_tickDelay edge from above: five starts packed into 2400 s hold the count above the ceiling from t = 6000 s until the first of them ages out of the trailing hour at 7200 s — 1200 s, one tick longer than alarm_delay. The alarm asserts at exactly 6900 s and falls again at 7200 s.
five_starts_in_forty_five_minutes_release_on_the_maturity_tickDelay edge from below, and the same five starts: spreading them over 2700 s holds the count above the ceiling for exactly alarm_delay (t = 6300 s to 7200 s). persist matures at 7200 s, the tick the fifth start’s window has already dropped the first, so the input is false when the timer comes due and nothing is ever reported. Three hundred seconds of burst spacing is the whole difference between this scenario and the one above.
five_starts_in_fifty_five_minutes_never_alarmsThe rolling counter’s blind spot, stated as a vector: five starts spread across 3300 s reach the same five-per-hour count, but only for the 300 s between the fifth start and the first one ageing out. Same starts in the same hour as the two scenarios above, no alarm — what the rule reports is cycling sustained above the ceiling, not every excursion through it.
cycling_stops_alarm_clearsRecovery: the boiler cycles at 6/h from t = 3600 s and the aquastat differential is widened after the twelfth start (t = 10200 s). The alarm holds from 6900 s until 11100 s and clears at 11400 s, the tick the trailing hour first holds four starts rather than five. The clear lags the last start by 1200 s here, and by up to a full count_window when the cycling stops after a shorter burst — the counter has to forget.
warmup_pace_asserts_on_two_startsThe warm-up artifact, and why the host NO_EVAL precondition is not optional. Two starts inside the first fifteen minutes (t = 300 s and 900 s) are read against elapsed time rather than against the window, so the count reports the extrapolated pace — well above four — and persist matures at 1200 s on the strength of two starts. The verdict decays with the growing divisor and clears at 1800 s. A single start from cold does not do this, and neither does a healthy 2/h or 3/h cadence; two starts inside ten minutes does.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "boiler_off_all_day",
      "description": "The boiler never fires. No rising edges, no count, no verdict \u2014 the baseline case that shows the counter reads zero rather than drifting.",
      "inputs": {
        "boiler_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "steady_firing_never_counts",
      "description": "The boiler is firing continuously from the moment the rule loads and never stops. `Logical.Edge` registers one rising edge at t = 0 against its `pre_u_start` default, and that pulse encloses no area because the moving average's first tick has dt = 0, so the count stays at zero. A boiler that holds fire for four hours is the opposite of short-cycling and reads as such.",
      "inputs": {
        "boiler_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "three_starts_per_hour",
      "description": "Healthy load-following: a 5-minute fire every 20 minutes, three starts an hour against a ceiling of four. The cadence begins at t = 3600 s so the reading is a completed-hour count rather than the warm-up extrapolation.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13500,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "four_starts_per_hour_is_exactly_the_threshold",
      "description": "Threshold edge from below: a 5-minute fire every 15 minutes puts exactly four starts in every trailing hour. The count evaluates to precisely 4.0 in IEEE-754 (12.0 x 1200/3600), so the strict `>` keeps it clear \u2014 this is a real boundary pin, not a near miss.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 8100,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9900,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11700,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13500,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_per_hour_is_the_first_faulted_cadence",
      "description": "Threshold edge from above, and the finest cadence a 300 s tick can resolve above four: a repeating 600/600/600/900/900 s spacing puts exactly five rising edges in every trailing hour. The count clears 4 at t = 6300 s (the fifth start) and the alarm follows one alarm_delay later at 7200 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9900,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11700,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13500,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7500,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "six_starts_per_hour_is_the_fastest_resolvable_cadence",
      "description": "A boiler cycling on a 10-minute period \u2014 five minutes of fire, five minutes off \u2014 which at the 300 s tick these vectors run is the Nyquist ceiling: one start per two ticks, 6/h. The count crosses 4 at t = 6000 s and the alarm lands at 6900 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11700,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13500,
            "value": false
          },
          {
            "t": 13800,
            "value": true
          },
          {
            "t": 14100,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "five_starts_in_forty_minutes_alarms_for_one_tick",
      "description": "Delay edge from above: five starts packed into 2400 s hold the count above the ceiling from t = 6000 s until the first of them ages out of the trailing hour at 7200 s \u2014 1200 s, one tick longer than alarm_delay. The alarm asserts at exactly 6900 s and falls again at 7200 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6900,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_in_forty_five_minutes_release_on_the_maturity_tick",
      "description": "Delay edge from below, and the same five starts: spreading them over 2700 s holds the count above the ceiling for exactly alarm_delay (t = 6300 s to 7200 s). `persist` matures at 7200 s, the tick the fifth start's window has already dropped the first, so the input is false when the timer comes due and nothing is ever reported. Three hundred seconds of burst spacing is the whole difference between this scenario and the one above.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_in_fifty_five_minutes_never_alarms",
      "description": "The rolling counter's blind spot, stated as a vector: five starts spread across 3300 s reach the same five-per-hour count, but only for the 300 s between the fifth start and the first one ageing out. Same starts in the same hour as the two scenarios above, no alarm \u2014 what the rule reports is cycling sustained above the ceiling, not every excursion through it.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6900,
            "value": true
          },
          {
            "t": 7200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "cycling_stops_alarm_clears",
      "description": "Recovery: the boiler cycles at 6/h from t = 3600 s and the aquastat differential is widened after the twelfth start (t = 10200 s). The alarm holds from 6900 s until 11100 s and clears at 11400 s, the tick the trailing hour first holds four starts rather than five. The clear lags the last start by 1200 s here, and by up to a full count_window when the cycling stops after a shorter burst \u2014 the counter has to forget.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 11400,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_pace_asserts_on_two_starts",
      "description": "The warm-up artifact, and why the host NO_EVAL precondition is not optional. Two starts inside the first fifteen minutes (t = 300 s and 900 s) are read against elapsed time rather than against the window, so the count reports the extrapolated pace \u2014 well above four \u2014 and `persist` matures at 1200 s on the strength of two starts. The verdict decays with the growing divisor and clears at 1800 s. A single start from cold does not do this, and neither does a healthy 2/h or 3/h cadence; two starts inside ten minutes does.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}