HW-0001 — Boiler short-cycling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:504010d1861bbe0d8a10b7f90528e0b1, 2026-08-17 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HW-0002, HW-0003, HW-0011, HW-0012, RTU-0001, AHU-0004 |
| Playbooks | hot-water-plant-faults |
| Source | HVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 124-125), HW-0001; Shohet et al. 2020; Meng et al. 2021 |
| Operating states | heating 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_startsshows 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
max_starts_per_hour | 4.0 | 1/h | cntHigh.t | Starts 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_window | 3600.0 | s | rate.delta | Trailing 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_scale | 12.0 | 1 | count.k | Rescales 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_delay | 900.0 | s | persist.delayTime | Continuous 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):
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:
- Boiler oversized for the current load — the fix is staging, a lead boiler with more turndown, or a buffer tank
- Aquastat differential too small, so the burner satisfies and relights a minute later. Check this first: it is free and remote
- Staging logic cycling between boilers, which no single boiler’s own controls would cause — the reason this rule is bound per boiler
- Short-circuiting in the piping — primary/secondary imbalance returning supply water to the boiler inlet, which reads as load satisfaction
- 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_timeis a tunable, not a graph parameter. The reference lists it at 10 min besidemax_starts_per_hourand 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.OnCountercounts 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 / dtcouples 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 isReals.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) needs5 ≤ 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 looserdt < 450 sonly 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_windowreads as a pace, not a count, andalarm_delaydoes not cover it. Whilet < count_windowthe average divides by elapsed time, so two starts in the first ten minutes report an extrapolated rate andpersistmatures on it (engine-pinned:yFaultat 1200 s, clearing at 1800 s as the divisor grows). AHU-0004 is immune because itsalarm_delayequals itscount_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.Edgecomparesuagainstpre_u_starton the first tick, so a boiler already firing when the rule loads registers a start at t = 0. It never reaches the count:dtis zero on that tick, so the pulse encloses no area.pre_u_startis 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.
ratecompares the accumulated integral now against its value onecount_windowago, 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 iscount_windowminus the span of the last five starts, a full hour when the boiler stops after a single burst. TrueDelayasserts at exactlyT + 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 thanalarm_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 defaultfalse), 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.jsondefines 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.jsonis 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: ruleand 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.
| Scenario | Description |
|---|---|
boiler_off_all_day | The 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_counts | 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. |
three_starts_per_hour | 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. |
four_starts_per_hour_is_exactly_the_threshold | 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 — this is a real boundary pin, not a near miss. |
five_starts_per_hour_is_the_first_faulted_cadence | 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. |
six_starts_per_hour_is_the_fastest_resolvable_cadence | A 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_tick | 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 — 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_tick | 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. |
five_starts_in_fifty_five_minutes_never_alarms | 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 — what the rule reports is cycling sustained above the ceiling, not every excursion through it. |
cycling_stops_alarm_clears | 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 — the counter has to forget. |
warmup_pace_asserts_on_two_starts | 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 — 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
}
]
}
]
}