Dense-Armor on Real Teleoperated-Robot-Arm Data (LeRobot)¶
Experiments 41 and 42 validated Dense-Armor's runtime detectors on a real accelerometer and a real lidar. This experiment looks for an actual, currently-open need rather than a hypothetical one -- and finds a real, complementary gap in a real, active open-source robotics project, not a clean textbook win.
Step 1. A real need, found by reading real GitHub issues, not guessed¶
huggingface/lerobot (27k stars) has two real,
open feature requests from the same contributor -- #3758
("lerobot-check-calibration": detect a systematic offset between a teleoperation
leader arm and its follower) and #3760
("lerobot-dataset-quality": flag outlier episodes via per-episode IQR). Both already
have a working PR in flight (#3762)
from the same author -- so this experiment does not propose to solve either literal
request; that would be redundant. Both proposed tools compute one static aggregate
number (a dataset-wide mean/std, or a per-episode outlier score). The real question:
is there structure neither approach can see?
Step 2. Two hypotheses, tested directly, both wrong¶
import pandas as pd
from huggingface_hub import hf_hub_download
path = hf_hub_download("lerobot/svla_so101_pickplace", repo_type="dataset",
filename="data/chunk-000/file-000.parquet")
df = pd.read_parquet(path)
df.columns.tolist(), df.episode_index.nunique()
(['action', 'observation.state', 'timestamp', 'frame_index', 'episode_index', 'index', 'task_index'], 50)
Real SO-101 arm teleoperation data (the exact hardware #3758's author reports a ~17°
calibration offset on) -- 50 real pick-and-place episodes, leader position (action)
and follower position (observation.state) per frame, 6 joints, ~30Hz.
Hypothesis 1 -- a slow within-episode drift: checked directly, not assumed.
Correlation between |action - observation.state| and frame index, averaged across all
50 episodes and 6 joints: 0.07. Not real -- episodes last 8-15 seconds, too short for
a slow drift to show.
Hypothesis 2 -- flag transient spikes in the raw offset: episode 0's offset does spike (from ~0.6 to ~23), but checking the leader's own velocity at those exact frames shows the spike is fully explained by ordinary control-loop lag during fast leader motion, not a fault. Applied naively, Dense-Armor would false-positive on every fast motion in a pick-and-place task. #3758's author had already anticipated this exact trap -- their proposal explicitly restricts to "stable frames" (velocity below a threshold) for exactly this reason.
Step 3. What survives: pose-dependent calibration offset¶
import numpy as np
VEL_THRESHOLD = 1.0 # matches #3758's own stable-frame filter
sub = df[df.episode_index == 0].sort_values("frame_index")
action = np.stack(sub["action"].values)
state = np.stack(sub["observation.state"].values)
diff = action - state
vel = np.abs(np.diff(action, axis=0, prepend=action[:1]))
stable = np.all(vel < VEL_THRESHOLD, axis=1)
x = diff[stable, 2] # joint 2 -- found to have the largest real spread of all 6 joints
np.round(x[::15], 2)
Restricted to the SAME stable-frame filter #3758 already proposes, joint 2's offset is not one constant value within this single episode -- it visits several distinct real plateaus (~0.6 → ~-1.5 → ~-3.8 → back to ~0.6) as the arm moves through real task poses (reach, grasp, lift, place). This is consistent with a real, documented phenomenon in robot manipulators: joint calibration error is configuration-dependent, not a single constant offset (Lu, He, Julius & Wen, "Configuration-Dependent Robot Kinematics Model and Calibration," arXiv:2510.19962 -- a single fixed-offset calibration is shown to be structurally insufficient, and pose-dependent calibration cuts positioning error by over 50% on 6-DoF arms). A single dataset-wide mean (#3758) or per-episode outlier score (#3760) cannot distinguish "this joint tracks worse at this specific pose" from "the arm generally drifted" -- both pool exactly the information this experiment finds structure in.
Step 4. Does the detector actually catch the real transitions?¶
from dense_armor.utility.arbiter import classify_segments
labels, deviation, uncertainty = classify_segments(x, radius=5, ref_mult=2, n_sigmas=3.0, spike_run_max=2)
# run-length summary: (label, start, end, median)
('clean', 0, 26, 0.62)
('regime', 27, 31, 0.53)
('clean', 32, 89, 0.53)
('spike', 90, 95, -3.84)
('clean', 96, 109, -1.48)
...
('regime', 128, 132, -4.55)
('clean', 133, 140, -3.56)
('regime', 141, 145, 1.79)
('clean', 146, 168, 0.62)
Yes -- every real transition boundary gets flagged as something (spike or regime),
never silently smoothed over. But this is a real, honest, mixed result, not a clean
win: the label choice is not always the intuitive one. The transition into the -3.8
plateau (frames 90-95) is labeled spike, not regime, even though the arm genuinely
settles there for a while -- because at 30Hz, that transition lasts only 6 frames before
spike_run_max=2's run-length rule and the causal window's own adaptation kick in. A
second, more extreme episode (22) shows the same pattern: a real, 70-frame sustained
plateau at ~-3.8 is correctly left clean once the detector adapts (consistent with
every prior experiment's causal-window behavior), but getting there involves several
short spike-labeled runs rather than one clean regime label.
Details¶
Tried the cheap fix first, it made things worse: before considering any new code, the
obvious cheap fix for the spike-vs-regime labeling above was tried directly --
spike_run_max (the run-length threshold separating the two labels) at 2, 5, 8, and 12.
Raising it did not help; it made every borderline transition MORE likely to be
labeled spike, not less (a run only becomes regime once its length exceeds
spike_run_max, so raising the threshold moves the boundary the wrong way for these
already-short, ~5-6-frame real transitions). No tested value produced better regime
labeling than the library default (2) already gives. This is a genuine, honestly
documented limitation of classify_segments for very short-duration real transitions at
this sample rate -- not a bug to hide, and not (yet) grounds for new library code: per
this project's own established discipline, a fix only gets promoted after proving itself
on more than one real case, and no working fix was found here at all.
Why joint 2: of the arm's 6 joints, joint 2's per-episode stable-frame median offset has by far the largest spread across the 50 episodes (std 0.93) versus the other 5 (std 0.10-0.20) -- checked directly before focusing the write-up here, not chosen to make the story look best.
What a static approach reports for comparison: pooling ALL 50 episodes' stable frames into one number (what #3758's own proposed tool would compute) gives mean=0.115, std=0.932 for joint 2 -- an unremarkable-looking number that hides the real ~4-5-unit within-episode swings this experiment found.
Honest scope: this is not a finished contribution. It demonstrates that real,
non-redundant structure exists (verified directly, twice-wrong-before-getting-it-right,
not assumed) and that Dense-Armor's causal detector catches the real transition points --
but the spike/regime labeling precision at 30Hz with spike_run_max=2 needs more work
before this would be a good PR to propose upstream. Not pursued further in this
experiment; see the maintainer's own notes for the decision on next steps.
Reproducing this: python scripts/robot_sensor_validation/run_lerobot_calibration_regime_analysis.py
re-downloads the dataset (~370KB, gitignored lerobot_data/) and regenerates
lerobot_calibration_regime_frozen.json; pytest tests/test_lerobot_calibration_regime.py
reads the already-frozen file, no download needed.
The velocity-gated stable-frame filter is now a shared, tested helper
(scripts/robot_sensor_validation/stable_frame_filter.py,
velocity_gated_stable_mask) -- extracted here so a future experiment can reuse it
without copy-pasting, but deliberately kept in Discovery, not promoted to dense-armor's
own utility/ package yet: this project's own rule (see how one_sided_upper_filter was
promoted only after proving itself, with a real ablation, on two independent real
datasets) is that a filter earns library-code status after a SECOND real case shows it
still helps, not from one experiment alone. Re-running the analysis after this extraction
reproduces byte-identical output to the version committed with the original experiment --
verified directly, not assumed.
Update: a second real case found a real gap in the function, then confirmed it after
fixing it. Applied naively to real IMU data (Experiment 41's UCI HAR dataset, gating
accelerometer-magnitude analysis by gyroscope magnitude), the original function was
WRONG -- it always differentiated the reference signal, correct for LeRobot's
position-like leader command, but wrong for a gyroscope, which is already a rate. Fixed
with an explicit already_rate parameter (False for position-like references,
True to threshold an already-rate-like reference directly) rather than silently
patched -- see stable_frame_filter.py's own docstring for the full account. Re-validated
both real cases through the corrected, unified function:
- LeRobot (already_rate=False): still byte-identical to the original committed result.
- Real UCI HAR WALKING segment (already_rate=True, subject 17, gyroscope magnitude
gating accelerometer magnitude): a real, modest, honestly-reported effect --
accelerometer-magnitude std drops from 0.208 (full signal) to 0.169 (gated-stable
subset, -19%), physically sensible (less real device rotation, less incidental
translational-acceleration variance) but not dramatic. See
validate_stable_frame_filter_second_case.py for the frozen, reproducible numbers.
Two independent real physical domains now correctly handled by one function -- the bar
this project sets before considering a Discovery helper for promotion to dense-armor.
Update: promoted. velocity_gated_stable_mask shipped in dense-armor v1.1.13
(dense_armor.utility.stable_frame_filter) after clearing that bar. The spike/regime
labeling gap below is unrelated to this filter and remains open.
Addendum: seven candidate fixes for the spike/regime labeling gap, all tested on real¶
data and rejected¶
Step 4 above left one real, disclosed gap: a genuine, sustained pose transition lasting
only 5-6 frames at 30Hz sometimes gets labeled spike instead of regime. Seven
candidate fixes were tried directly against the real data already collected (episodes 0
and 22, then the full 50-episode dataset) -- none worked. Reported honestly rather than
silently abandoned, per this project's own discipline.
1. Raising spike_run_max (already documented above in Details) -- tested at 2, 5, 8,
12; made labeling worse, not better, because raising the run-length threshold moves the
spike/regime boundary the wrong way for transitions that are already short.
2. Net-displacement (does the signal return to its pre-event value, or settle at a new
one?) -- the idea: compare the clean run's median right before an event to the
clean run's median right after it. A real, sustained transition should leave a lasting
net displacement; noise that just perturbs and reverts should not.
- On the two hand-inspected episodes, a fixed absolute threshold (~0.3) cleanly recovered
both previously-flagged mislabeled
spikeruns (net displacement 2.01 and 4.43) without breaking any of the five correctly-labeled short spikes (net displacement all <0.11). - Tested against all 50 episodes (207 non-
cleanruns, not just the 2 inspected by eye), the same fixed threshold did not generalize: 66/101 (65%) ofspike-labeled runs exceeded it, which would mean two-thirds of all detected spikes are actually mislabeled real transitions -- implausible, and a clear sign of overfitting to 2 examples. - Normalizing by the local MAD scale (the same robust scale
classify_segmentscomputes internally, via_robust_center_scale) did not fix this:spikeruns' normalized net displacement has median z=2.72 (n=66) againstregimeruns' median z=15.83 (n=54), but the distributions overlap too much for a workable threshold -- even at z=3.0 (the samen_sigmasthe arbiter itself uses), 48% of spikes would still flip. - Honest interpretation: in a continuously-moving arm signal at 30Hz, a
cleanrun adjacent to aspikeis often still mid-motion, not a true resting plateau -- so "returns to baseline" is not a reliable signature here.
3. Kinematic feasibility (a Lagrangian-inspired, dynamics-free check) -- the idea:
use only the actuator's kinematic upper limits (no mass, inertia, or torque model
needed) to test whether the follower's implied motion during an event is physically
achievable, or whether it looks like a sensor/encoder artifact. The Feetech STS3215
datasheet publishes a speed rating but not a fixed acceleration limit (acceleration on
these bus servos is a software-configured trapezoidal-profile register, not a hardware
constant, and the specific driver configuration used to record this dataset is unknown)
-- so a datasheet number would have been fabricated. Used instead a real, conservative
empirical ceiling: the largest observation.state acceleration this exact system ever
produced across all 50 episodes' full (not just stable-filtered) frames, joint 2:
2662.5 units/s².
Checked whether any spike- or regime-labeled event's implied follower acceleration
exceeds that ceiling: 0/101 spikes and 0/106 regimes do (spike median 484.1,
regime median 403.4, both comfortably under the ceiling). This is not a failed test --
it is a real, clarifying negative result: every labeled event, spike or regime
alike, is physically genuine servo motion, not an artifact. The spike/regime ambiguity
was never a real-vs-fake-data problem, so a physical-plausibility filter has nothing to
discriminate on here -- it rules out sensor noise as the cause and confirms the actual
open question is purely one of duration classification, which is exactly what
spike_run_max already (unsuccessfully, per fix #1) tries to solve.
4. Phase-space curvature (Menger curvature via Pythagorean triangle sides) -- the
idea: embed the signal as (diff[t], d(diff)/dt) and compute, for every three
consecutive points, the curvature of the triangle they form (κ = 4*Area /
(side1*side2*side3), sides via Euclidean/Pythagorean distance). Noise that loops back to
its starting value has to turn sharply somewhere (high curvature); a real transition
moving toward a new state should be smoother (low curvature).
- On the two hand-inspected episodes, mixed: episode 22 supported the hypothesis clearly (spike median curvature 0.98 vs regime median 0.00), episode 0 did not (spike median 0.30 vs regime median 0.43, the wrong direction) -- the same overfitting warning sign as fix #2.
- Aggregated per run (mean/max/median curvature within each run) across all 50 episodes,
every statistic overlapped heavily between
spikeandregime: e.g. run-mean median 1.056 (spike) vs 0.920 (regime), run-max median 2.106 (spike) vs 2.292 (regime) -- no usable separation.
5. Distributional divergence (Jensen-Shannon, dynamic binning) -- the idea: compare
the empirical distributions of the clean run right before an event and right after it;
a real regime change should look like two different distributions, noise should not.
Implemented with dynamically-sized histogram bins (adapted to each window's sample count)
and scipy.spatial.distance.jensenshannon.
- Across all 50 episodes (149 runs with usable neighbor windows),
spikemedian JSD = 0.871,regimemedian JSD = 1.000 (saturated at the maximum) -- heavy overlap; at every threshold tried (0.3 to 0.9), 46-92% of spikes still exceeded it. - Root cause: with very few samples per window (often <10, sometimes 1), even a small positional difference saturates JSD to its maximum almost every time -- the same underlying problem as fix #2, dressed differently.
6. Velocity sign-changes (a sample-efficient stand-in for "resonance") -- true
frequency-domain analysis (FFT, resonant-mode detection) needs many oscillation cycles to
resolve a frequency; these events are 5-6 samples long, nowhere near enough -- computing
one anyway would have reported noise dressed as physics, so it was not attempted. Instead
counted zero-crossings of the discrete velocity d(diff)/dt within each event, as a
sample-efficient proxy for "oscillates and reverts" (spike) vs "moves monotonically to a
new state" (regime).
- Across all 50 episodes:
spikeruns had a higher median sign-change count thanregimeruns (1.00 vs 0.00), which is the expected direction, but the overlap was total -- at threshold "0 sign changes", 46% of spikes and 58% of regimes both qualify; at threshold "<=1", 80% vs 84%. No usable separation.
7. pressure_valve (an existing, more mature library detector, not a from-scratch
attempt) -- before writing any more one-off code, checked whether dense_armor.utility
already had something better. It does: robust_filters.py's pressure_valve combines
four classical robust estimators (Chauvenet, Tukey, Hampel, sigma-clipping) via a
minimum-variance (BLUE) weighting, with an adaptive threshold that widens near genuine
distribution shifts via the module's own _jensen_shannon -- which, unlike the
hand-rolled version in fix #5, already uses adaptive bin counts and Laplace smoothing
specifically to avoid the saturation problem fix #5 ran into (documented in its own
docstring, verified independently before this addendum was written).
Applied directly to the same real event windows (episodes 0 and 22): it does not solve
this problem either, but for a different, clarifying reason -- it answers a different
question than the one asked here. pressure_valve measures how much a point deviates
from its local neighborhood, not whether that deviation persists afterward. The clearest
counter-example: episode 22's run[70:74], a run fix #2 had already confirmed to be a pure
transient (net displacement 0.109, returns almost exactly to its pre-event value), got
the highest pressione (12.58) of every event checked -- higher than every confirmed
real regime transition (1.69-3.18). Duration/persistence and instantaneous deviation
magnitude are simply not the same signal.
Where this leaves the experiment, in plain terms: the signal being analyzed (one joint's leader-follower offset, 30 samples/second) sometimes jumps to a new level and stays there (a real, lasting pose-dependent calibration change), and sometimes jumps and reverts within the same handful of samples (a real but brief motor movement, not sensor noise -- fix #3 already ruled that out). Both can last exactly 5-6 samples, so duration alone cannot tell them apart, and unambiguously telling them apart requires seeing what happens after the event -- which is exactly the information a short window doesn't have yet. Seven independent, real-data-tested approaches to finding some other early signature in the shape of this one signal (run-length, endpoint displacement, phase-space curvature, distributional divergence -- twice, oscillation pattern, neighborhood-deviation magnitude) all failed to generalize past the 2 examples they were first tried on. That is not a proof that no signature exists, but seven independent failures on the same question is real evidence that whatever would solve this is probably not "a cleverer function of this same signal" -- it would more likely need substantially more real episodes (50 was not enough to validate even the fixes that looked clean on 2 examples), or genuine external information this dataset does not provide (e.g., ground-truth timestamps for when the human operator's intent actually changed). Neither has been attempted. Not pursued further here.
Reproducing the numbers above: scripts/robot_sensor_validation/lerobot_calibration_regime_frozen.json
has the frozen per-run labels and medians used for the net-displacement analysis; the
kinematic-feasibility, curvature, JSD, sign-change, and pressure_valve checks all read
observation.state/the stable-frame diff directly from the same cached parquet
(scripts/robot_sensor_validation/lerobot_data/, gitignored) and are not frozen to a
JSON file since they recompute cheaply from data already on disk.