Data in, one sample at a time¶
Robot data comes in many forms: files, recordings, arrays, live sensors. This page turns all of them into the same stream of readings, which every tool in the library then reads one at a time.
Every estimator in the library learns from a stream of samples: a Signal (named channels with
units and a timestamp) and, when there is one, a target y. Robot data arrive in many shapes:
log files, ROS bags, arrays, live sensors at different rates. This page turns all of them into
the same stream, merges sensors in time order, makes test streams whose changes happen at a known
moment, and ends with a model that refuses to learn from a fault.
1. A robot log file becomes a stream¶
A CSV file with the columns of the CASPER recording as it is distributed on Kaggle (Kayan et al. 2023), one row per sample, with a timestamp column and a column holding the six joint velocities as a list; here three joints and three rows.
from pathlib import Path
from dense_armor.utility.stream import iter_csv
p = Path("arm.csv")
p.write_text(
"Timestamp,Actual Joint Velocities\n"
'0.00,"[0.1, -0.2, 0.0]"\n'
'0.05,"[0.2, -0.1, 0.0]"\n'
'0.10,"[0.3, 0.0, 0.1]"\n'
)
for sig, y in iter_csv(p, time_col="Timestamp", array_cols=["Actual Joint Velocities"]):
print(sig.t, {k: round(float(v), 2) for k, v in sig.items()})
0.0 {'Actual_Joint_Velocities_0': 0.1, 'Actual_Joint_Velocities_1': -0.2, 'Actual_Joint_Velocities_2': 0.0}
0.05 {'Actual_Joint_Velocities_0': 0.2, 'Actual_Joint_Velocities_1': -0.1, 'Actual_Joint_Velocities_2': 0.0}
0.1 {'Actual_Joint_Velocities_0': 0.3, 'Actual_Joint_Velocities_1': 0.0, 'Actual_Joint_Velocities_2': 0.1}
Each row becomes one Signal with its timestamp; the list column is split into one
channel per joint. The file is read one row at a time, so a 24-hour log uses the same memory as a
three-row one; start and stop read only a time window and stop at the end of it. An empty or
unreadable cell becomes a missing channel, not an error.
2. Two sensors, one timeline¶
A camera at 30 frames per second and joint encoders at 100 Hz give two streams; an estimator needs them as one stream, in time order.
import numpy as np
from dense_armor.utility.stream import iter_array, merge_by_time
cam = iter_array(np.array([[1.0], [2.0]]), t=np.array([0.0, 1 / 30]), names=["frame"])
joints = iter_array(np.array([[0.1], [0.2], [0.3], [0.4]]), t=np.arange(4) / 100, names=["q0"])
for sig, _ in merge_by_time(cam, joints):
print(round(sig.t, 3), sig.names[0])
The samples come out sorted by time across the two sensors; at equal times (0.0 here) the order of the arguments decides, camera first. The merge keeps only one sample per stream in memory, so it works on live sensors as well as on files.
3. Choosing and reshaping channels¶
A sample with a joint position, a torque and a temperature; the model needs only the first two, with the torque scaled.
from dense_armor.roles import Signal
from dense_armor.utility.compose import FuncTransformer, Select
sig = Signal(values=[0.5, 2.0, 9.0], names=["q0", "tau0", "temp"], units=["rad", "N m", "C"], t=1.0)
pipe = Select(keys=("q0", "tau0")) | FuncTransformer(lambda v: v * 2.0, keys=("tau0",))
out = pipe.transform_one(sig)
print({k: float(v) for k, v in out.items()}, out.t, out.units)
Select keeps the named channels (Discard drops them), FuncTransformer applies a
function to some channels; joined with | they form a pipeline like any other in the library.
The timestamp and the units of the kept channels travel with the sample.
4. A test stream whose concept changes¶
To test a drift detector you need a stream whose rule changes at a known place. This binary stream changes its concept once, half way, gradually: near the change each sample comes from the old or the new concept with a probability that follows a sigmoid.
from dense_armor.utility.datasets import DriftStream
ds = DriftStream(n_samples=20000, n_features=2, n_drifts=1, kind="gradual", spacing=20.0, seed=0)
ds.data()
c = ds.concepts_
print([round(float(c[a:a + 1000].mean()), 2) for a in range(7000, 13000, 1000)])
A concept is the rule that ties the input features to the class. In this generator each
concept is two Gaussian clusters, one per class, in a random direction; the paper's own
StreamGenerator builds them from make_classification and the Madelon rules (section 3,
page 7). The stream visits one concept, then the next; the change sits at sample 10000. Each
number in the output is the share of samples from the new concept in a window of 1000, from
sample 7000 to 13000; it rises smoothly through the change (0.42 just before, 0.61 just
after).
The weight of the new concept at position $p \in [0, 1]$ of the stream is the sigmoid that Ksieniewicz and Zyblewski (2020) introduce for all three drift types in section 3.2.1 (page 8). The paper does not give its closed form; the generator uses the standard logistic form,
$$s(p) = \frac{1}{1 + e^{-\text{spacing}\,(p - d)}}$$
with $p$ the position of the sample as a fraction of the stream, $d = 0.5$ the position of the
drift (half way) and spacing how sharp the change is. At $p = d$ the sigmoid is exactly $0.5$;
with spacing $= 20$ it goes from $0.12$ at $p = 0.4$ to $0.88$ at $p = 0.6$. The same sigmoid
drives the three drift types of the paper (section 3.2, pages 8–9): "sudden" is the limit for
a large spacing and swaps the concept at once, "gradual" draws the old or the new concept at
each sample with probability s(p), "incremental" moves the clusters continuously with the
same weight.
5. A robot arm with a fault at a known time¶
A labelled joint stream from the real URDF of a Panda arm. Torque is the turning force the motors apply at each joint; on a moving arm it is not free, it is fixed by the motion through the arm's own dynamics. The stream has a periodic motion, torques from the library's inverse dynamics, sensor noise, and from second 2 a 5 kg payload on the last link.
import numpy as np
from dense_armor.utility.datasets import SyntheticArm
ds = SyntheticArm("test/fixtures/urdf/panda.urdf", period_s=2.0, rate_hz=50.0, n_cycles=2,
fault_at_s=2.0, fault="payload", payload_mass=5.0, seed=0)
r0, r1 = [], []
for sig, y in ds.stream():
q, qd, qdd = ds.trajectory(sig.t)
r = float(sig["tau_1"] - ds.nominal_torque(q, qd, qdd)[1])
(r1 if y else r0).append(r)
print(ds.payload_link, round(np.mean(r0), 3), round(np.std(r0), 3), round(np.mean(r1), 2))
The residual is what is left of the measured torque once the expected one is subtracted:
$$r = \tau_{\text{meas}} - \tau_{\text{model}}.$$
On a healthy arm it is noise: mean -0.004 N·m and standard deviation 0.051 N·m, exactly the torque noise the stream adds. On a faulty arm it carries the fault: it jumps to a mean of -12.54 N·m.
The expected torque comes from the arm's inverse dynamics,
$$\tau = M(q)\,\ddot q + C(q, \dot q)\,\dot q + g(q),$$
with $q$ the joint positions, $\dot q$ the joint velocities, $\ddot q$ the joint accelerations, $M(q)$ the mass matrix (how the arm's inertia couples the joints), $C(q, \dot q)\,\dot q$ the Coriolis and centrifugal torques, and $g(q)$ the gravity torques. The payload adds its own gravity torque
$$\tau_{\text{payload}} = m\,g\,J_z(q),$$
where $m$ is the payload mass, $g$ the acceleration of gravity and $J_z(q)$ the vertical row of
the Jacobian of the link that carries the payload (here panda_link7, the flange): the vertical
speed of that link for a unit joint velocity. y is 0 before the fault and 1 after, so the
stream also scores detectors.
6. A model that does not learn the fault¶
A recursive least-squares model estimates the residual offset, the constant value the model
adds to its prediction to best fit the samples it has seen; on a healthy arm it is about zero.
Protected asks an anomaly detector before each sample and skips learning on the ones the
detector flags; the detector is a Hampel scorer wrapped in AnomalyGate, which also keeps it
from learning flagged samples. The same model without protection is shown next to it for
comparison.
from dense_armor.roles.anomaly_detector import AnomalyGate
from dense_armor.roles.protection import Protected
from dense_armor.utility.anomaly.filters import HampelScorer
from dense_armor.utility.datasets import SyntheticArm
from dense_armor.utility.learn.online_dynamics import RecursiveLeastSquares
ds = SyntheticArm("test/fixtures/urdf/panda.urdf", period_s=2.0, rate_hz=50.0, n_cycles=2,
fault_at_s=2.0, fault="payload", payload_mass=5.0, seed=0)
gate = AnomalyGate(HampelScorer(radius=10, n_sigmas=5.0, feature="r"))
prot = Protected(model=RecursiveLeastSquares(feature_keys=["one"]), detector=gate)
bare = RecursiveLeastSquares(feature_keys=["one"])
for sig, y in ds.stream():
q, qd, qdd = ds.trajectory(sig.t)
x = {"r": float(sig["tau_1"] - ds.nominal_torque(q, qd, qdd)[1]), "one": 1.0}
gate.learn_one(x)
prot.learn_one(x, x["r"])
bare.learn_one(x, x["r"])
print(round(prot.model.predict_one({"one": 1.0}), 3), round(bare.predict_one({"one": 1.0}), 3))
After the fault, the protected model still says the healthy offset is -0.003 N·m; the unprotected one has averaged the fault in and says -6.271 N·m, about half of the -12.5 N·m step because half of its samples came after the fault. In the test suite the same run flags 99 of the 100 faulty samples and 1 of the 100 healthy ones. A model protected this way can be used to keep estimating "normal" while the robot is in trouble.
API reference¶
stream ¶
Streams: iterators of (Signal, y) pairs in time order.
iter_array ¶
iter_array(
X: Any,
y: Any = None,
t: Any = None,
names: Iterable[str] | None = None,
units: Iterable[str] | None = None,
) -> Iterator[tuple[Signal, Any]]
Iterate rows of an array as (Signal, y) pairs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
X
|
Any
|
array of shape |
required |
y
|
Any
|
optional labels of shape |
None
|
t
|
Any
|
optional timestamps in seconds. If |
None
|
names
|
Iterable[str] | None
|
channel names in column order. |
None
|
units
|
Iterable[str] | None
|
channel units. |
None
|
Yields:
| Type | Description |
|---|---|
tuple[Signal, Any]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if a length does not match. |
Source code in dense_armor/utility/stream/array.py
iter_csv ¶
iter_csv(
path: str | Path,
target: str | None = None,
time_col: str | None = None,
columns: list[str] | None = None,
array_cols: list[str] | None = None,
start: float | None = None,
stop: float | None = None,
) -> Iterator[tuple[Signal, Any]]
Iterate rows of a CSV file as (Signal, y) pairs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
path to the CSV file. |
required |
target
|
str | None
|
label column, or |
None
|
time_col
|
str | None
|
timestamp column in seconds, or |
None
|
columns
|
list[str] | None
|
scalar feature columns. |
None
|
array_cols
|
list[str] | None
|
columns holding a list literal |
None
|
start
|
float | None
|
skip rows before this time. |
None
|
stop
|
float | None
|
stop at the first row past this time. |
None
|
Yields:
| Type | Description |
|---|---|
tuple[Signal, Any]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on a requested column that is not in the file. |
Source code in dense_armor/utility/stream/csv.py
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | |
merge_by_time ¶
Merge streams in time order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*streams
|
Iterable[tuple[Signal, Any]]
|
iterables of |
()
|
Yields:
| Type | Description |
|---|---|
Signal
|
|
Any
|
timestamps keep the order of the arguments. |
Raises:
| Type | Description |
|---|---|
ValueError
|
if a sample has |
Source code in dense_armor/utility/stream/merge.py
iter_rosbag ¶
Iterate a ROS 1 or ROS 2 bag as (Signal, y) pairs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
ROS 1 |
required |
topics
|
list[str] | None
|
topic names to read. |
None
|
Yields:
| Type | Description |
|---|---|
tuple[Signal, Any]
|
|
Raises:
| Type | Description |
|---|---|
ImportError
|
if |
Source code in dense_armor/utility/stream/rosbag.py
datasets ¶
Datasets: ready-made streams for tests and experiments.
Casper ¶
Casper(
path: str | Path,
time_col: str = "Timestamp",
file: str = "right_arm.csv",
target_col: str | None = None,
)
CASPER UR3e joint stream.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
folder containing the CSV files. |
required |
time_col
|
str
|
name of the timestamp column, in seconds. Default
|
'Timestamp'
|
file
|
str
|
name of the CSV file inside |
'right_arm.csv'
|
target_col
|
str | None
|
name of the label column, if present. Default
|
None
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
if the file does not exist. |
Examples:
Source code in dense_armor/utility/datasets/casper.py
stream ¶
stream(
columns: list[str],
start_h: float | None = None,
stop_h: float | None = None,
) -> Iterator[tuple[Signal, Any]]
Iterate the joint stream, in time order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
columns
|
list[str]
|
names of the joint columns to expand; each cell holds a six-element list literal and becomes six channels. |
required |
start_h
|
float | None
|
skip rows before |
None
|
stop_h
|
float | None
|
stop as soon as a row goes past |
None
|
Yields:
| Type | Description |
|---|---|
tuple[Signal, Any]
|
|
Source code in dense_armor/utility/datasets/casper.py
n_rows ¶
Number of data rows in the CSV file.
DriftStream ¶
DriftStream(
n_samples: int,
n_features: int,
n_drifts: int,
kind: str,
spacing: float = 10.0,
seed: int | None = 0,
)
Synthetic binary stream with concept drift.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_samples
|
int
|
number of samples in the stream. |
required |
n_features
|
int
|
number of input dimensions. |
required |
n_drifts
|
int
|
number of concept changes. Zero means stationary. |
required |
kind
|
str
|
one of |
required |
spacing
|
float
|
sharpness of the sigmoid, in inverse position units. Higher values give more abrupt transitions. |
10.0
|
seed
|
int | None
|
seed of the random generator. |
0
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on a bad |
Examples:
>>> from dense_armor.utility.datasets import DriftStream
>>> ds = DriftStream(n_samples=50, n_features=3, n_drifts=2,
... kind="gradual", spacing=20.0, seed=1)
>>> X, y = ds.data()
>>> X.shape
(50, 3)
>>> ds.drift_positions
array([0.33333333, 0.66666667])
Source code in dense_armor/utility/datasets/drift_stream.py
data ¶
Generate the whole stream.
Returns:
| Type | Description |
|---|---|
ndarray
|
|
ndarray
|
|
Source code in dense_armor/utility/datasets/drift_stream.py
stream ¶
Yield (Signal, y) pairs in time order.
Source code in dense_armor/utility/datasets/drift_stream.py
SyntheticArm ¶
SyntheticArm(
urdf_path: str | Path,
period_s: float,
rate_hz: float,
n_cycles: int = 4,
fault_at_s: float | None = None,
fault: str | None = None,
fault_joint: int | None = None,
friction_b: float = 20.0,
payload_mass: float = 2.0,
payload_link: str | None = None,
noise_std: tuple[float, float, float] = (
0.001,
0.005,
0.05,
),
seed: int | None = 0,
)
Periodic joint stream from a URDF with an optional fault.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
urdf_path
|
str | Path
|
URDF readable by
:class: |
required |
period_s
|
float
|
period of one sinusoidal cycle, in seconds. |
required |
rate_hz
|
float
|
sampling rate, in Hz. |
required |
n_cycles
|
int
|
number of cycles generated. |
4
|
fault_at_s
|
float | None
|
time from the start when the fault begins, in
seconds. |
None
|
fault
|
str | None
|
|
None
|
fault_joint
|
int | None
|
index of the joint with the |
None
|
friction_b
|
float
|
viscous coefficient for |
20.0
|
payload_mass
|
float
|
mass added for |
2.0
|
payload_link
|
str | None
|
link carrying the payload. Default: the last link of the longest chain. |
None
|
noise_std
|
tuple[float, float, float]
|
standard deviation of the Gaussian sensor noise on
|
(0.001, 0.005, 0.05)
|
seed
|
int | None
|
seed of the random generator. |
0
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on bad arguments. |
Examples:
>>> from dense_armor.utility.datasets import SyntheticArm
>>> ds = SyntheticArm(
... "test/fixtures/urdf/panda.urdf",
... period_s=1.0, rate_hz=50.0,
... fault_at_s=0.5, fault="payload", seed=0,
... )
Source code in dense_armor/utility/datasets/synthetic_arm.py
trajectory ¶
Sinusoidal position, velocity and acceleration at t.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t
|
float
|
time from the start, in seconds. |
required |
Returns:
| Type | Description |
|---|---|
tuple[ndarray, ndarray, ndarray]
|
|
Source code in dense_armor/utility/datasets/synthetic_arm.py
nominal_torque ¶
Inverse dynamics of the arm, without noise or fault.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
q
|
ndarray
|
joint positions. |
required |
qd
|
ndarray
|
joint velocities. |
required |
qdd
|
ndarray
|
joint accelerations. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
``mass_matrix(q) @ qdd + bias_forces(q, qd) |
ndarray
|
|
Source code in dense_armor/utility/datasets/synthetic_arm.py
payload_torque ¶
Gravity torque of the payload mass, m * g * J_z(q).
stream ¶
Yield (Signal, y) pairs at rate_hz.
Each :class:Signal has channels q_j, qd_j, tau_j
for every joint; t is the sample time in seconds; y is
0 before fault_at_s and 1 after.
Yields:
| Type | Description |
|---|---|
tuple[Signal, int]
|
|
Source code in dense_armor/utility/datasets/synthetic_arm.py
compose ¶
Compose: small transformers to select, drop and reshape channels.
FuncTransformer ¶
Bases: Transformer
Apply fn to channels, or to the whole dict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Callable
|
a callable taking one value (when |
required |
keys
|
tuple[str, ...] | None
|
names of the channels to transform. |
None
|
whole
|
bool
|
if True, |
False
|
Examples:
>>> from dense_armor.utility.compose import FuncTransformer
>>> f = FuncTransformer(
... lambda d: {"y": sum(d.values())}, whole=True
... )
>>> f.transform_one({"a": 1.0, "b": 2.0})
{'y': 3.0}
Source code in dense_armor/utility/compose/func.py
learn_one ¶
transform_one ¶
Apply the function and return the new sample.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
a :class: |
required |
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
Any
|
A new :class: |
Source code in dense_armor/utility/compose/func.py
Discard ¶
Bases: Transformer
Drop the named channels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
keys
|
tuple[str, ...]
|
names of the channels to drop. Keys not present in the input are silently ignored. |
()
|
Examples:
>>> from dense_armor.roles import Signal
>>> from dense_armor.utility.compose import Discard
>>> s = Signal(values=[1.0, 2.0], names=["a", "b"],
... units=["", ""], t=0.0)
>>> sorted(Discard(keys=("a",)).transform_one(s))
['b']
Source code in dense_armor/utility/compose/select.py
learn_one ¶
transform_one ¶
Return a copy without the dropped keys.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
a :class: |
required |
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
Any
|
A new :class: |
Source code in dense_armor/utility/compose/select.py
Select ¶
Bases: Transformer
Keep only the named channels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
keys
|
tuple[str, ...]
|
names of the channels to keep, in the order given. Keys not present in the input are silently ignored. |
()
|
Examples:
>>> from dense_armor.roles import Signal
>>> from dense_armor.utility.compose import Select
>>> s = Signal(values=[1.0, 2.0], names=["a", "b"],
... units=["", ""], t=0.0)
>>> sorted(Select(keys=("b",)).transform_one(s))
['b']
Source code in dense_armor/utility/compose/select.py
learn_one ¶
transform_one ¶
Return a copy with only the selected keys.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
a :class: |
required |
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
Any
|
A new :class: |
Source code in dense_armor/utility/compose/select.py
Details¶
iter_csvuses Python'scsvmodule, one row at a time; a list cell"[a, b, c]"becomes channels<name>_0, <name>_1, ...(spaces in the name become underscores);start/stopselect a window ontime_coland stop reading afterstop.iter_rosbag(path, topics=None)reads ROS 1 and ROS 2 bags with the pure Python packagerosbags(extra[ros]);sensor_msgs/msg/JointStatebecomes channelsq_<joint>,qd_<joint>,tau_<joint>with the time of the message header.merge_by_timeis a k-way merge with a heap; it assumes each input stream is already in time order.shuffle(stream, buffer_size, seed)shuffles streams without timestamps in a bounded buffer.Casper(path).stream(columns, start_h, stop_h)readsright_arm.csvfrom a local copy of the CASPER dataset (Kayan et al. 2023, section III-A: a UR3e arm logged at about 20 Hz, a 24-hour test with about half of the samples anomalous); hours are counted from the first timestamp. The code repository of the dataset is MIT (https://github.com/hkayann/CASPER-PerCom), the data are CC BY-SA 4.0 (https://www.kaggle.com/datasets/hkayan/industrial-robotic-arm-anomaly-detection); the library reads the user's copy and redistributes nothing.DriftStream: each concept is two Gaussian clusters, one per class, placed along a random direction; the class of each cluster alternates between concepts, so a model trained on one concept is wrong on the next. Each sample uses the drift nearest to its position, so every transition is continuous on both sides."sudden"is the limit of the sigmoid for a largespacingand does not use it; in"incremental"the label follows the concept with the larger weight. Implemented from the description in Ksieniewicz and Zyblewski (2020), sections 3.2.1 to 3.2.3 (pages 8–9).SyntheticArm: sinusoidal joint trajectories at 30 % of each joint's range, inverse dynamics withRigidBodyModel, compiled once per URDF and process; faults:"friction"(viscousb * qdon one joint) or"payload"(a mass on the last link of the longest chain, or onpayload_link).Protectedpasses the timestamp only to models that accept it;HampelScorer.thresholdis itsn_sigmas, whichAnomalyGateuses to decide.- Sources: Ksieniewicz, P., Zyblewski, P. (2020), "stream-learn — open-source Python library for difficult data stream batch analysis", arXiv:2001.11077. Kayan, H., Rana, O., Burnap, P., Perera, C. (2023), "CASPER: Context-aware anomaly detection system for industrial robotic arms".