Buckets
A bucket watches a table's columns and raises an incident when a value
leaves its declared normal range. A value below a column's lo or above its
hi opens an incident after open_after consecutive breaches, and closes it
after close_after consecutive in-range samples — so a single noisy reading
does not spam an alarm, and a cleared condition resolves on its own. It is the
anomaly-detection sibling of Calculated Fields and
Table Triggers: all three drain a table per row, but a bucket's
job is to turn a stream of values into a small stream of open/close events.
TableTrigger(vehicle_events) → BucketChecks(vehicle_events)
The node drains the source and writes incidents itself; there is no
TableSink. Definitions live as data in x_buckets, fetched fresh every run —
a save is a hot reload, no restart, no republish.
A definition is name + type + columns + counts + target
| part | meaning |
|---|---|
| name | a name for the bucket |
| type | fixed — a fixed [lo, hi] range per column (the mode below). profile (a learned baseline) is reserved for a later phase and is read but inert. |
| columns | each watched column with an optional lo and/or hi; either bound may be omitted (a one-sided range) |
| open_after | consecutive breaches before an incident opens (default 1) |
| close_after | consecutive in-range samples before it closes (default 1) |
| target | the table incidents are written to; empty = the shared bucket_incidents |
The range is plain data. The engine has no idea what a column means — you derive the bounds upstream (from a spec sheet, a calibration table, a learned percentile) and store them as numbers. The database stays general-purpose.
Incidents
Each open/close transition is one row in the sink table:
| column | meaning |
|---|---|
entity | the source row's entity |
ts | the sample's timestamp |
bucket | the bucket name |
channel | the column that breached |
event | open or close |
value | the breaching (or clearing) value |
lo, hi | the range that was crossed |
A per-bucket target table lets an alarm pipeline and an audit pipeline keep
separate sinks; the table is created on first write (like an ILP series) and
can be read, joined, and — being an ordinary table — watched by another rule.
Example
A bucket named battery_ranges over an electric-bus telemetry table, watching a
cell-temperature channel pc2 for the range −40 … 65 °C and opening only after
three consecutive out-of-range samples:
{
"cols": {
"pc2": { "lo": -40, "hi": 65 }
},
"open_after": 3,
"close_after": 3,
"target": "battery_alarms"
}
A run of pc2 = 88, 120, 180 opens one incident (open, value 180) on
battery_alarms; a later run of in-range samples closes it. The engine emits the
transition once, not once per breaching sample.
How it decides
Per (bucket, entity, channel) the engine keeps a small state machine: a
running count of consecutive breaches and of consecutive in-range samples. A
breach increments the breach count and resets the in-range count; reaching
open_after opens the incident (and emits open). An in-range sample does the
reverse; reaching close_after closes it (and emits close). Between those
edges nothing is emitted — the incident is a level, not a per-sample event.
A forward-only guard on the feed's monotonic cursor means a replay or the
pipeline's own echo of an already-seen row never double-counts a breach: only a
strictly newer sample advances the machine. State is bounded — one small record
per watched (entity, channel) — and page-scoped, so cost tracks the rows a
page actually touches, not the size of the fleet.
Querying incidents
bucket_incidents (or your target) is an ordinary table:
SELECT entity, ts, channel, value, hi
FROM battery_alarms
WHERE bucket = 'battery_ranges' AND event = 'open'
ORDER BY ts DESC
LIMIT 20;
Because the sink is a normal table, a trigger can watch it in turn — e.g. open a work order, or fan an incident out to an external alerting channel — composing anomaly detection into the rest of the pipeline.