SYS-0010 — Sensor spike / rate-of-change violation
| Status | verified — engine e2ff2f8, cxf:fnv1a128:188c9fa8f8976ddf90245592e44abb9c, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 3 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | CLU-09 |
| Suppresses | — |
| Suppressed by | — |
| Related | SYS-0009, SYS-0005, AHU-0028, RTU-0003 |
| Playbooks | sensor-drift |
| Source | Library-authored per the accepted internal sensor-health design note (local-only, not distributed; §2 stance, §4.1-4.2 block choice, §4.4 vector strategy); Yang, Cho, Tae, Zaheeruddin 2008, Energy Conversion and Management 49(8) 2291-2306, doi:10.1016/j.enconman.2008.01.029 — sequential rule-based temperature sensor fault detection in AHUs; Liao, Cai, Cheng, Dubey, Rajesh 2021, Sensors 21(13) 4358, doi:10.3390/s21134358 — a cheap deterministic sensor-rule layer under a heavier diagnostic layer; Dey & Dong 2016, Energy and Buildings 130 177-187, doi:10.1016/j.enbuild.2016.08.017 — the sensor-bias ambiguity this family removes before the equipment rules are read; Sibling precedent: AHU-0028 and RTU-0003 (physical-plausibility rules already shipping under the same stance), AHU-0023 (discrete baseline plus Subtract/Abs), VAV-0001 (per-binding placeholder parameter); Engine pin e2ff2f8: crates/oce-blocks/src/discrete.rs (UnitDelay warmup and sample grid), logical_timing.rs (TrueDelay init branch) |
| Operating states | all — a step larger than the measured process can produce is implausible whether the equipment is running, idling, or off, and the rule takes no run-state conjunct (see Deviations) |
Preconditions (host-enforced): Delivery quality is resolved host-side before this rule runs, and this rule is unusually exposed to it: a value re-served from cache after a comms outage, a gap the host interpolated badly, and a poll interval that slipped all present as a sample-to-sample step that no graph reading one point can tell from a failing transmitter. The reference’s ch.4 gap handling runs first and this rule sits on top of it — it is right about the number it was given and says nothing about how the number arrived. sample_period MUST equal the host’s tick interval: the rule is written for, pinned at, and only means what it looks like it means at one sample of lookback (see Deviations). history_warmup MUST be set to exactly twice sample_period and re-set with it; it is not an independent tunable. max_step_per_sample is a per-binding number in the bound point’s units, and the shipped default is a worked example for sensor_value := sat at a 60 s tick, not a site value — deployed unretuned on a pressure, flow, humidity or CO2 point it is comparing against an arbitrary number. Read yHistoryOk before yFault: while it is false the delay line still holds its y_start seed, and the verdict is NO_EVAL rather than a healthy sensor.
Points: sensor_value
Outputs:
yFault— True on each tick where the reading moved further from the previous sample than max_step_per_sample, once the startup inhibit has expired. Momentary by construction — a single-sample outlier asserts for two ticks, a level shift for one — and the host owns any dwell it wants on topyHistoryOk— Evaluability signal — false for the first history_warmup after load, the worst-case window in which Discrete.UnitDelay is still emitting its y_start seed and the computed step is a fabrication. False means NO_EVAL and the host must ignore yFault
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
max_step_per_sample | 10.0 | varies — the bound point’s units | tooFast.t | Largest change the measured process can physically produce between two samples one sample_period apart. PER-BINDING SITE CONFIGURATION in the units of whatever sensor_value is bound to: the shipped 10.0 is the worked example for sensor_value := sat at a 60 s tick (argued in Detection Logic) and carries no authority on a Pa, L/s, %RH or ppm point. Re-derive it, never scale it, when sample_period changes. |
sample_period | 60.0 | s | prev.samplePeriod | The rule’s lookback — one sample of Discrete.UnitDelay history. MUST equal the host’s tick interval; at any other ratio the delayed sample is between one and two periods old and the threshold stops meaning a sample-to-sample step (see Deviations). |
history_warmup | 120.0 | s | historyOk.delayTime | Width of the startup inhibit that masks the UnitDelay y_start seed, and the window in which yHistoryOk is false. MUST be set to exactly 2 × sample_period and re-set whenever sample_period changes: a rule loaded between sample instants keeps emitting the seed until its second instant, so the seed can survive two full periods. Shorter lets a fabricated spike out; longer buys nothing. |
Description
Every measured process in a building has mass behind it. Air is dragged past a probe that takes half a minute to come to temperature, water carries the thermal inertia of the pipe, a zone’s CO₂ concentration is the integral of people breathing into a volume. Each puts a ceiling on how far a reading can move between two samples, and that ceiling belongs to the physics rather than to the equipment: it does not change when the fan stops. A reading that clears it did not come from the process — it came from a failing element, an intermittent termination, a transmitter that changed range, or a point now serving a different number than it used to. It is the only rule in the family whose fault is momentary: flatline accuses a sensor of standing still for hours and pair bias accuses two of disagreeing for a shift, while a spike happens on one sample and is gone. That fact drives every timing decision below, and it is why this is the one card in the library with no persistence timer on its fault path. The stance is AHU-0028’s and RTU-0003’s: delivery quality stays host-owned, physical plausibility is a fault of a piece of equipment, because a sensor is equipment.
The adjudicates contract. While yFault is true, sensor_value is
invalid: the host must return NO_EVAL for every rule on this equipment instance
that consumes it, deriving that set from the other cards’ own points lists so
it stays complete as rules are added. The verdict is invalid_while_active
rather than ambiguous because attribution is not in doubt — one point went
somewhere it could not have gone. Read yHistoryOk first: while it is false the
verdict is NO_EVAL, never a healthy sensor.
Detection Logic
step = |sensor_value − UnitDelay(sensor_value, sample_period)|
yHistoryOk = true from history_warmup after load (false ⇒ host reports NO_EVAL)
yFault = step > max_step_per_sample AND yHistoryOk
Block graph (rule.cxf.jsonld):
Seven blocks in two strands. The upper strand is the measurement, ending in a
strict Reals.GreaterThreshold: a step landing exactly on max_step_per_sample
reads clear. The lower strand is a startup inhibit — a constant true through a
TrueDelay with delayOnInit = true, false for the first history_warmup
after load and true forever after — which is also the yHistoryOk output.
The delay line’s warmup is a fabricated spike, masked deliberately.
Discrete.UnitDelay seeds its state from y_start and emits it until the
second sample instant, so at the CDL default of 0.0 the first computed step on
a 22 °C duct is |22 − 0| = 22 — more than twice the bound, on every load and
every restart. The gate is two sample periods wide because that is the longest
the seed can survive; it costs one real sample of blindness when a load happens
to be grid-aligned.
yFault is one or two ticks wide by design. A single-sample outlier
violates the bound twice, going out and coming back; a level shift that stays —
a units change, a rescaled transmitter — violates it exactly once, because the
next sample is compared against the shifted value. There is no latch and no
accumulated timer: each event is judged on its own tick, and any dwell is the
host’s to add.
The worked example: sensor_value := sat at a 60 s tick, which is the
shipped max_step_per_sample = 10.0 and no other binding. Coil-leaving air can
fall 8-10 K within a minute of a large stage cutting in, and a duct probe in a
2.5 m/s stream filters that with a 30-60 s time constant, so one 60 s sample
carries 60-85% of a true step: the largest genuine one-minute excursion reaching
the reading is of order 6-8 K. Ten sits above every transient the plant can
produce and below the 15 K per minute that is not a temperature at all. Nothing
about that argument survives a change of binding or of sample_period — a 300 s
sample admits the entire stage transient — so re-derive the bound from the
physics rather than multiplying this one.
Possible Diagnoses
Library-authored; nothing published fixes a cause list for a step bound.
- A failing sensing element — an intermittent thermistor or RTD, a cracked lead, a probe that opens when the duct vibrates. Isolated outliers between long stretches of good data, which is the two-tick case
- Loose, corroded or wet terminations, or a signal pair sharing conduit with a switching inductive load — the spikes correlate with that equipment’s cycles rather than with the sensed process
- A transmitter or A/D channel that changed range or scaling — a 4-20 mA transmitter re-ranged during service, a jumper moved, an input reconfigured from 10 k to 1 k. A level shift, one tick wide
- A units change nobody announced — a point re-served in °F on a °C-declared binding. Also one tick wide, and this is the only rule in the library that notices
- A point rebound to a different physical sensor by a controller download or a BAS integration edit — the reading is honest and is no longer the sensor the rest of the rules think they are reading
- A host-side delivery artifact in the costume of a sensor fault — a value re-served from cache when comms came back, a bad interpolation, a slipped poll. Not a sensor fault at all
- A correct reading and a wrong threshold — diagnose this first if the alarms cluster on stage changes and mode transitions
Energy Impact
COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. There is no direct waste term: a spiking sensor burns nothing, and its cost is entirely in what reads it — a controller that takes one 40 K outlier as real and slams a valve, an economizer that changes mode on a phantom reading, the diagnostic rules that then miss a real fault or invent one. PNNL EEM-01 (sensor recalibration) puts the recoverable range at 0-5% of site energy across an entire sensor population at roughly 15% prevalence, which is the honest ceiling for the whole family. Confidence is MEDIUM rather than LOW, a deliberate departure from AHU-0028 and RTU-0003: those compare several sensors and cannot say which is wrong, while this one reads a single point and its finding is unambiguous by construction. It is not HIGH because the threshold ships as a placeholder and because the host’s delivery layer can manufacture the identical step (diagnosis 6).
Emissions Impact
QUALITATIVE_EMISSIONS, MEDIUM confidence; no direct emissions, because a reading
neither burns fuel nor draws power. Scope is 1|2 for the same reason
AHU-0028 records it that way: it depends on which subsystem the bad number
distorts. A spiking supply-air temperature that provokes preheat lands in Scope
1; one that provokes mechanical cooling or drops an economizer out of service
lands in Scope 2, and the same sensor can do both in different seasons.
Avoided-emissions basis: N/A.
Deviations
- No persistence timer on the fault path, and that decision is the card.
Every other rule document here drives
yFaultfrom aTrueDelay, because they detect conditions that persist. A spike does not, and the timer would delete findings rather than merely be redundant: at the pinTrueDelayemits false on a rising edge and true on the next tick the input still holds, so any positivedelayTimeup to one tick means “two consecutive violating samples” — keeping the single-sample outlier and silently deleting the entire level-shift class (units change, re-ranged transmitter, rebound point), which between them is the most common thing this rule finds. Zero is not available either:positive_durationclamps at 0 and the init branch honoursdelayOnInitonly whendelayTime > 0, so a zero-delay timer would pass the warmup artifact straight through. The timer therefore moves to the startup inhibit. What the library gives up is debounce, and that is host policy. - The warmup gate is two sample periods wide because the seed can survive
two.
Discrete.UnitDelaypromotes staged→held only at sample instants and its grid is anchored to absolute model time, so a load landing between instants stages nothing, the next instant promotes seed→seed, and the first real sample does not appear until the instant after that. A one-period gate is correct only for grid-aligned loads and lets a fabricated 22 K step through on every other one — a fault the rule invents on a restart nobody chose the timing of. The cost when the load is aligned is one real sample of blindness. y_startis left at the CDL default of 0.0. Seeding it to a plausible mid-scale reading would also hide the artifact, but it buries a block-level fact instead of documenting it and turns a graph constant into a per-point site value that buys no diagnostic power.sample_periodandhistory_warmupare two card parameters carrying one decision, and the linter cannot enforce the ratio. The multi-pathcxf: [...]form AHU-0023 uses is unavailable, because the two block parameters take different values (samplePeriodand2 × samplePeriod) and that form sets every listed path to the same number. So the invarianthistory_warmup = 2 × sample_periodis stated in both descriptions, inpreconditions, and here: a host that retunes the sample period and forgets the gate gets a rule that fabricates a spike on some fraction of its restarts. Same class of coupled-parameter hazard as HP-0001’s slope and intercept.sample_periodmust equal the host’s tick interval.UnitDelay’s grid is anchored to absolute model time and it holds between instants, so the emitted value is between one and two periods old — the lookback age runs over[P, 2P). WhenPequals the tick that collapses to exactly one tick and the rule means what the equation looks like; when it does not, the rule means “an excursion exceeding the bound somewhere within one to two sample periods”, a defensible test but a different one, with a per-window magnitude for a threshold and a warmup gate2P/tickticks wide. The arithmetic is read from the engine at the pin and deliberately not vector-pinned, since pinning it would require shipping the configuration this card tells hosts not to use.Reals.Derivativeis not used, and could not be. It is the block the equation suggests and it is wrong on the engine’s own arithmetic: itskandTareRealInputconnectors rather than parameters, its filter discretizes tok·s·(1 + dt/T)for a ramp of slopes(twice the actual slope at a 300 s tick withT = 300 s; holding the error under 5% needsT ≥ 20·dt, a hundred minutes of lag in a spike detector), and its step response ignoresdt, so the same 5 K jump reads identically whether it took 60 s or 600 s.- Strict
>. CDLRealshas noGreaterEqual, so a step of exactlymax_step_per_samplereads clear, which errs toward silence. Sites whose BAS quantises the bound point coarsely will land on the boundary often enough to notice, and should set the threshold between two quantisation levels. Reals.Absdiscards the sign and the card does not try to recover it. A signal-conditioning failure that rails low and one that rails high are the same finding here; splitting them would double the graph to distinguish two cases that lead to the same work order.- No
equip_activeconjunct, which is where this rule parts company with SYS-0009. Flatline needs the gate, because a still reading on idle equipment is the correct answer. A spike needs none for the mirror reason: the ceiling is set by the mass of the process, not by whether anything drives it, and an idle system moves slower, so the running case is the permissive one. A gate would also cost a second binding obligation on a family whose binding cost is already the main argument against it. max_step_per_sampleships as a per-binding placeholder, the VAV-0001 arrangement in a stronger form: here even the dimension is unknown until binding. The number is defended above for one binding and no other. Set it 3× too high and the rule never fires; 3× too low and it alarms on every stage change the plant makes.points/sys.points.jsonstates this insensor_value’s own notes.- The rule cannot see drift, ramps, or anything that stays under the bound. Five consecutive 5 K steps carry a reading from 22 to 47 °C — physically absurd in a duct, and silent, because each individual step is legal. A step bound sees steps. Slow monotone drift is structurally invisible to this rule and to SYS-0009 alike, which is the argument for SYS-0005 being a third shape rather than a variation on these two.
adjudicatescards must not be suppressible, sosuppressed_byis empty and must stay empty: if an equipment fault could silence the sensor rule that invalidates it, the suppression graph has a cycle with a wrong answer at both ends.suppressesis empty for a different reason — the fan-out of an adjudicated point is derived by the host from other cards’pointslists, not hand-written here, which is the entire argument for keying the field to the point. AHU-0028’s fourteen-entrysuppresseslist is the counter-example: correct today, silently incomplete the first time someone authors an AHU rule that readsmat.- Severity 3 and COMFORT_ENERGY follow AHU-0028 and RTU-0003 rather than
the fan-out argument. Severity 3 is
faults/sys/README.md’s index value and is not this card’s to change. PROTECTIVE is declined on evidence: in this library it means avoided physical damage (PMP-0001’s dry-running seal, RTU-0001’s short-cycled compressor), and stretching it to cover avoided false alarms would make one category mean two unrelated things. Note SYS-0009 takes the opposite call, so the family is split and reconciling it is library-wide. clusters: [], and CLU-09 is the open question.clusters/clusters.jsongives CLU-09 the triggerAHU-0028and lists SYS-0005 and SYS-0006 as members; with the FC-100 family landing, the trigger is arguably one of the sys rules and 062 becomes a member. That is a single-writer file, so this card declares nothing and this bullet is the flag.sensor-driftis the right playbook and does not yet name this rule; adding SYS-0009 and SYS-0010 to its Applies-To row is the playbook owner’s edit. Step 1’s portable-reference comparison and step 3’s recalibrate-or-replace both transfer. Step 2’s BAS offset does not: an offset corrects a bias and does nothing for a sensor that jumps.- Role-point binding breaks the library’s canonical-name convention, on
purpose. The host’s instance configuration records which real point
sensor_valueis, and that same record resolvesadjudicates.points, so the binding is a required artifact of the design rather than an extra one. SCHEMA.md’s points contract carries the exception; the alternative — thirty copies of this graph, one per point-and-family pair — is thirty chances to drift apart with no cross-card diff check to catch it. - Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
Notes
Read yHistoryOk first. It is false for two sample periods after the rule
loads, and in that window a false yFault is silence rather than a clean bill of
health. On a host that reloads rules on every configuration change that window
recurs.
When this rule fires, look at the trend before dispatching anyone, because the shape of the event names the cause faster than any test does. Isolated single samples between long stretches of clean data are an intermittent connection or a failing element — chase terminations, and note whether the spikes line up with a compressor or a lighting contactor rather than with the sensed process. A single step that never comes back is usually not a sensor failure at all: it is a re-ranged transmitter, a units change, or a point rebound during a controller download, and the fix is a configuration edit. Check what changed in the BAS that day before touching the sensor.
Then check the tick. Alarms that cluster on stage changes and mode transitions
rather than falling at random usually mean max_step_per_sample was inherited
from another binding or survived a change to sample_period untouched. The
threshold is a physical claim about one sensor measuring one process at one
sample interval, and it does not travel — not between points, not between
equipment types, not between tick rates.
Know what this rule cannot do. It sees steps, so it is blind to the drift most sensor work is actually about: a transmitter losing a degree a month never violates a step bound. SYS-0009 covers the opposite extreme, a reading that stopped moving at all. The middle needs a second sensor to compare against, which is SYS-0005’s job and the reason this family has three shapes.
Test Vectors
12 scenarios, clock step 60 s over 1800 s.
| Scenario | Description |
|---|---|
steady_reading_and_the_unit_delay_warmup_artifact | A still 22 degC reading, and the block-level artifact this rule is built around. UnitDelay seeds both state words from y_start (0.0, the CDL default), so at t = 0 the graph computes |
identical_step_after_the_warmup_gate_opens | The pair for the scenario above: the same 22 degC step arrives at t = 120, the tick history_warmup expires, where the delay line is guaranteed to hold a real sample. Identical arithmetic, opposite verdict - which is what proves the gate masks a fabrication rather than swallowing findings. The alarm is exactly one tick wide because the reading stays at its new value. |
genuine_step_inside_the_warmup_gate_is_not_reported | The price of the gate, pinned rather than hidden: these vectors start on a sample boundary, so the delay line already holds a real sample at t = 60 and this 22 degC step is genuine - and it is suppressed anyway, because history_warmup covers the worst case (a rule loaded between sample instants, where the seed survives two full periods) rather than the best. One sample of blindness per load, in exchange for never fabricating one. |
single_sample_outlier_reported_on_both_edges | The classic spike: one bad sample of 60 degC at t = 600 in an otherwise still 22 degC signal. It violates the bound twice - on the way out and on the way back - so the alarm is two ticks wide. A rule with a persistence timer on the fault path would read the first edge as noise and never assert. |
units_change_is_one_tick_wide | The level-shift archetype, and the reason this rule carries no persistence timer: a 12.8 degC supply reading is re-served as the 55 that was its degF value, and stays there. The bound is violated on exactly ONE tick, because the sample after the shift is compared against the shifted value. Any TrueDelay on the fault path erases this whole class of fault - units changes, rescaled transmitters, points rebound by a controller download. |
step_exactly_on_the_bound_reads_clear | Threshold edge, on the line: 22.0 -> 32.0 degC is a step of exactly max_step_per_sample. Reals.GreaterThreshold is strict, so a step landing on the bound is not above it and nothing is reported. |
step_just_under_the_bound_reads_clear | Threshold edge from below: 22.0 -> 31.9 degC, a tenth of a kelvin short of the bound, still clear. |
step_just_over_the_bound_is_reported | Threshold edge from above: 22.0 -> 32.1 degC asserts for the one tick that carries the step. All three sides of the bound are pinned so the strictness cannot change silently. |
downward_step_is_reported_the_same_as_an_upward_one | Reals.Abs throws the sign away: 22.0 -> 4.0 degC is an 18 K collapse, reported exactly as an 18 K jump would be. A sensor whose signal conditioning fails low reads as clearly as one that rails high, and the output cannot say which happened. |
ramp_under_the_bound_every_tick_is_invisible | The blind spot, pinned rather than left for a site to discover: five consecutive 5 K steps carry the reading from 22 to 47 degC in five minutes - two and a half times the bound in total, and physically impossible in a duct - and every individual step is under max_step_per_sample, so the rule is silent. A step bound sees steps. Drift and ramps belong to SYS-0005. |
two_outliers_report_independently | Recovery: the rule holds no latch and accumulates no timer, so a second outlier fifteen minutes after the first is reported on its own merits and the quiet stretch between them reads clean. The host owns any dwell it wants on top - the graph reports the ticks the physics was violated and nothing else. |
small_magnitude_binding_has_no_warmup_artifact_to_mask | Whether the y_start seed fabricates a spike at all depends on the bound point’s magnitude: a signal that lives near zero - a differential pressure at rest, a valve position, a flow at no load - starts within the bound and would never have alarmed on tick one. The gate does not care, and that is the point: masking is unconditional, so the card does not have to reason about every binding’s zero. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 1800
},
"scenarios": [
{
"name": "steady_reading_and_the_unit_delay_warmup_artifact",
"description": "A still 22 degC reading, and the block-level artifact this rule is built around. UnitDelay seeds both state words from y_start (0.0, the CDL default), so at t = 0 the graph computes |22 - 0| = 22 degC - more than twice max_step_per_sample, a fabricated spike on every load. yHistoryOk is false across the whole seed window and yFault never asserts.",
"inputs": {
"sensor_value": 22.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "identical_step_after_the_warmup_gate_opens",
"description": "The pair for the scenario above: the same 22 degC step arrives at t = 120, the tick history_warmup expires, where the delay line is guaranteed to hold a real sample. Identical arithmetic, opposite verdict - which is what proves the gate masks a fabrication rather than swallowing findings. The alarm is exactly one tick wide because the reading stays at its new value.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 120,
"value": 44.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yFault",
"from_s": 120,
"to_s": 120,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "genuine_step_inside_the_warmup_gate_is_not_reported",
"description": "The price of the gate, pinned rather than hidden: these vectors start on a sample boundary, so the delay line already holds a real sample at t = 60 and this 22 degC step is genuine - and it is suppressed anyway, because history_warmup covers the worst case (a rule loaded between sample instants, where the seed survives two full periods) rather than the best. One sample of blindness per load, in exchange for never fabricating one.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 60,
"value": 44.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "single_sample_outlier_reported_on_both_edges",
"description": "The classic spike: one bad sample of 60 degC at t = 600 in an otherwise still 22 degC signal. It violates the bound twice - on the way out and on the way back - so the alarm is two ticks wide. A rule with a persistence timer on the fault path would read the first edge as noise and never assert.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 60.0
},
{
"t": 660,
"value": 22.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 660,
"equals": true
},
{
"output": "yFault",
"from_s": 720,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "units_change_is_one_tick_wide",
"description": "The level-shift archetype, and the reason this rule carries no persistence timer: a 12.8 degC supply reading is re-served as the 55 that was its degF value, and stays there. The bound is violated on exactly ONE tick, because the sample after the shift is compared against the shifted value. Any TrueDelay on the fault path erases this whole class of fault - units changes, rescaled transmitters, points rebound by a controller download.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 12.8
},
{
"t": 600,
"value": 55.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 600,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "step_exactly_on_the_bound_reads_clear",
"description": "Threshold edge, on the line: 22.0 -> 32.0 degC is a step of exactly max_step_per_sample. Reals.GreaterThreshold is strict, so a step landing on the bound is not above it and nothing is reported.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 32.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "step_just_under_the_bound_reads_clear",
"description": "Threshold edge from below: 22.0 -> 31.9 degC, a tenth of a kelvin short of the bound, still clear.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 31.9
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "step_just_over_the_bound_is_reported",
"description": "Threshold edge from above: 22.0 -> 32.1 degC asserts for the one tick that carries the step. All three sides of the bound are pinned so the strictness cannot change silently.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 32.1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 600,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "downward_step_is_reported_the_same_as_an_upward_one",
"description": "Reals.Abs throws the sign away: 22.0 -> 4.0 degC is an 18 K collapse, reported exactly as an 18 K jump would be. A sensor whose signal conditioning fails low reads as clearly as one that rails high, and the output cannot say which happened.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 4.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 600,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "ramp_under_the_bound_every_tick_is_invisible",
"description": "The blind spot, pinned rather than left for a site to discover: five consecutive 5 K steps carry the reading from 22 to 47 degC in five minutes - two and a half times the bound in total, and physically impossible in a duct - and every individual step is under max_step_per_sample, so the rule is silent. A step bound sees steps. Drift and ramps belong to SYS-0005.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 600,
"value": 27.0
},
{
"t": 660,
"value": 32.0
},
{
"t": 720,
"value": 37.0
},
{
"t": 780,
"value": 42.0
},
{
"t": 840,
"value": 47.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "two_outliers_report_independently",
"description": "Recovery: the rule holds no latch and accumulates no timer, so a second outlier fifteen minutes after the first is reported on its own merits and the quiet stretch between them reads clean. The host owns any dwell it wants on top - the graph reports the ticks the physics was violated and nothing else.",
"inputs": {
"sensor_value": [
{
"t": 0,
"value": 22.0
},
{
"t": 300,
"value": 60.0
},
{
"t": 360,
"value": 22.0
},
{
"t": 1200,
"value": 60.0
},
{
"t": 1260,
"value": 22.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 240,
"equals": false
},
{
"output": "yFault",
"from_s": 300,
"to_s": 360,
"equals": true
},
{
"output": "yFault",
"from_s": 420,
"to_s": 1140,
"equals": false
},
{
"output": "yFault",
"from_s": 1200,
"to_s": 1260,
"equals": true
},
{
"output": "yFault",
"from_s": 1320,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "small_magnitude_binding_has_no_warmup_artifact_to_mask",
"description": "Whether the y_start seed fabricates a spike at all depends on the bound point's magnitude: a signal that lives near zero - a differential pressure at rest, a valve position, a flow at no load - starts within the bound and would never have alarmed on tick one. The gate does not care, and that is the point: masking is unconditional, so the card does not have to reason about every binding's zero.",
"inputs": {
"sensor_value": 5.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yHistoryOk",
"from_s": 120,
"to_s": 1800,
"equals": true
}
]
}
]
}