Skip to content

17 · Sensors, noise and filtering

Part5 · Perception and estimation
Run typeCPU · Mac or Colab CPU
Prerequisites12
Learn firstProbability and Distributions · Stochastic Processes
Time~45 min
Codeexperiments/17-sensors-noise-filtering/experiment.py
Status✅ done

Act on this lesson

Tick each step when you have done it. Saved in this browser only.

Every filter buys lower noise with delay; the right window is the one where the two errors are equal for the motion you actually have.

A joint controller (lesson 12) needs the joint’s position and velocity. A real arm gives you neither directly. It gives you encoder readings that jump in small steps and wobble with electrical noise, and it gives you velocity only if you compute it yourself from consecutive readings. Smoothing the readings removes the wobble but makes them late, and a feedback loop reacts badly to late data (lesson 37). This lesson measures that trade-off on a simulated SO-101 joint and ends with a rule for picking a filter.

Before you read · 2 questions. Guess first; the lesson tells you why.

1. You replace a single jittery reading by the mean of the last 10 readings. What do you get?

  • A calmer number that describes where the joint was a moment ago.
  • A calmer number that is just as current as the raw reading.
  • The same noise, because every reading carries the same noise level.

Why: A moving average over nn readings reports the value from the middle of its window, (n−1)/2(n-1)/2 samples ago; its noise falls as σ/n\sigma/\sqrt n. Build it, Step 2 measures both.

2. The best window for velocity (n = 65) is much longer than for position (n = 5). Why?

  • Velocity changes more slowly than position, so lag does not matter for it.
  • Velocity is measured by a separate, noisier sensor.
  • Differentiating divides the remaining noise by a small Δt, so the noise term is far larger and its minimum moves to a longer delay.

Why: Raw velocity noise is 3.50 rad/s against a true velocity RMS of 0.63 rad/s. The SO-101 model has no velocity sensor; velocity comes from differences of readings.

Think of a jittery speedometer. If you read the needle once, you get a noisy number. If you average the last ten readings, the number is calm, but it describes where the car was a moment ago, not where it is now. The more readings you average, the calmer and the older the number gets.

So any smoothing filter has two errors pulling in opposite directions:

  • Noise error: what is left of the jitter. It shrinks as you average more.
  • Lag error: how far the joint moved since the readings you averaged. It grows as you average more, and it grows faster when the joint moves faster.

The best filter is where the sum of the two is smallest. Where that is depends on how fast your joint moves. That is the whole lesson.

flowchart TD
    Q["true angle q(t)"] --> E["encoder<br/>rounds to steps"]
    E --> N["+ electrical noise"]
    N --> F["filter<br/>(average / low-pass)"]
    F --> C["controller"]
    F -. "difference of<br/>two samples" .-> V["velocity estimate"]
    V --> C

If you have written a rolling average over a stream of numbers, you already know the first filter. Readings arrive every dt seconds (here dt = 0.002, i.e. 500 per second); Build it writes both filters in full.

# Moving average: the mean of the last n readings
y[k] = mean(z[k - n + 1 : k + 1])
# First-order low-pass: move a fraction alpha toward each new reading
alpha = dt / (tau + dt) # tau = 1 / (2 pi fc); small alpha = heavy smoothing
y[k] = y[k - 1] + alpha * (z[k] - y[k - 1])

A moving average over n readings reports the value from the middle of its window, (n−1)/2(n-1)/2 samples ago; the low-pass filter lags by about its time constant tau.

FilterKnobDelayNoise left (std)
Moving averagewindow nnn−12 Δt\frac{n-1}{2}\,\Delta tσ/n\sigma/\sqrt{n}
First-order low-passcutoff fcf_c≈τ=12πfc\approx \tau = \frac{1}{2\pi f_c}shrinks as fcf_c falls

Here Δt\Delta t is the time between readings and σ\sigma is the noise standard deviation of one reading. The lag error is roughly speed × delay: a joint moving at 0.6 rad/s seen through a 16 ms filter is about 0.6×0.016≈0.010.6 \times 0.016 \approx 0.01 rad behind.

Math details: where σ/√n and the error trade-off come from

Averaging cuts noise by √n. If the nn readings carry independent noise viv_i with variance σ2\sigma^2, the noise in their mean is 1n∑vi\frac1n\sum v_i, whose variance is 1n2⋅nσ2=σ2/n\frac{1}{n^2}\cdot n\sigma^2 = \sigma^2/n. The standard deviation falls as σ/n\sigma/\sqrt n. Independence matters: correlated noise (for example a slow drift) is not removed this way.

Delay of the moving average. For a slowly varying signal q(t)q(t), a Taylor expansion of the window mean around now gives 1n∑i=0n−1q(t−iΔt)≈q(t−n−12Δt)\frac1n\sum_{i=0}^{n-1} q(t - i\Delta t) \approx q\big(t - \tfrac{n-1}{2}\Delta t\big): the output is the signal delayed by half the window.

Low-pass filter. yk=yk−1+α(zk−yk−1)y_k = y_{k-1} + \alpha(z_k - y_{k-1}) is the discrete form of τy˙=z−y\tau\dot y = z - y. For frequencies well below fc=1/(2πτ)f_c = 1/(2\pi\tau) its phase delay tends to τ\tau; near and above fcf_c it is smaller, which the experiment shows at 0.5 Hz.

The trade-off. With delay tdt_d the filtered position is roughly q(t−td)q(t - t_d) plus residual noise, so

erms2≈σ2 g(td)⏟noise, falls with delay+q˙ 2‾  td2⏟lag, grows with delaye_{\text{rms}}^2 \approx \underbrace{\sigma^2\, g(t_d)}_{\text{noise, falls with delay}} + \underbrace{\overline{\dot q^{\,2}}\; t_d^2}_{\text{lag, grows with delay}}

where gg decreases with tdt_d (for the moving average, g=1/ng = 1/n with td=n−12Δtt_d = \frac{n-1}{2}\Delta t). A falling term plus a growing term has a minimum.

Velocity is harder. Velocity from two readings, (zk+1−zk−1)/(2Δt)(z_{k+1} - z_{k-1})/(2\Delta t), divides their noise by a small number: the noise grows by a factor of about 1/Δt1/\Delta t. So the noise term is much larger for velocity, and its minimum sits at a much longer delay.

Go deeper (random variables, variance, noise processes): Probability and Distributions, Stochastic Processes.

SensorReportsTypical error
Encoder (NN counts/rev)angle rounded to steps of 2π/N2\pi/Nquantization (step size) + electrical noise
Gyroscopeangular velocityslowly varying bias + noise; integrating a bias gives an angle that drifts away linearly
Accelerometerspecific forceat rest it reads gravity, not zero

MuJoCo sensors (jointpos, jointvel, gyro, accelerometer, …) write exact values into mjData.sensordata. Their noise attribute is only stored metadata: the docs state it does not affect the simulation (MuJoCo Modeling · sensors). You add noise yourself.

You will now write the sensor model and both filters yourself, in about 40 lines of NumPy, and check each against SciPy or a formula from Core idea. Only the true joint motion comes from MuJoCo (the Experiment’s simulate), so Build it and the Experiment score the same 5000 readings.

Type the steps into a file of your own and run it after each one. The finished functions are in physai/estimation.py; uv run python experiments/17-sensors-noise-filtering/build.py runs all five steps (output in build_output.txt).

An encoder rounds the angle to whole counts; the electronics add noise on top:

def encoder_reading(q, counts, sigma, rng):
"""What an encoder reports for true angles q: rounded to steps of 2 pi / counts, plus white noise."""
step = 2 * np.pi / counts # one count, in rad
return np.round(q / step) * step + rng.normal(0, sigma, np.shape(q))

Rounding to steps of Δ\Delta leaves an error between −Δ/2-\Delta/2 and Δ/2\Delta/2, spread evenly, so its standard deviation is Δ/12\Delta/\sqrt{12}. Check that on a million random angles, then on the lesson’s signal:

== Step 1. The encoder: 4096 counts/rev, sigma 10 mrad ==
step = 1.534 mrad, max |rounding error| = 0.767 mrad (step / 2 = 0.767)
rounding error std 0.4427 mrad vs step / sqrt(12) = 0.4428 mrad
lesson signal: max |ours - Exp. encoder| = 0.0e+00 rad
expected reading error sqrt(sigma^2 + step^2/12) = 10.01 mrad, measured 9.95 mrad

The rounding part (0.44 mrad) is small next to the 10 mrad noise, so the expected reading error is 10.01 mrad. This seed’s noise draw comes out 0.6 % low, which is why the Experiment measures 9.95 mrad.

The running sum turns each window mean into one subtraction, so the cost does not grow with nn:

def moving_average(x, n):
"""Causal moving average: the mean of the last n samples (fewer at the start), as in lesson 17."""
c = np.cumsum(np.insert(x, 0, 0.0))
i = np.arange(len(x))
lo = np.maximum(0, i + 1 - n)
return (c[i + 1] - c[lo]) / (i + 1 - lo)

Three checks: the same numbers as a plain loop over windows, the noise left on pure white noise against 1/n1/\sqrt n, and the delay. A ramp makes the delay exact: the mean of the last nn samples of a straight line is its value (n−1)/2(n-1)/2 samples ago.

== Step 2. Moving average: window mean, noise left, delay on a ramp ==
n = 5: max |ours - loop over windows| = 2.2e-16
n noise std left 1/sqrt(n) ramp lag samples (n-1)/2
5 0.4459 0.4472 2.0 2.0
17 0.2415 0.2425 8.0 8.0
65 0.1226 0.1240 32.0 32.0
def low_pass(x, fc, dt):
"""First-order low-pass with cutoff fc (Hz): move a fraction alpha toward each new sample."""
tau = 1.0 / (2 * np.pi * fc) # time constant (s)
alpha = dt / (tau + dt) # small alpha = heavy smoothing
y = np.empty(len(x))
y[0] = x[0] # start at the first reading
for k in range(1, len(x)):
y[k] = y[k - 1] + alpha * (x[k] - y[k - 1])
return y

This is a recursive filter, yk=αzk+(1−α)yk−1y_k = \alpha z_k + (1-\alpha) y_{k-1}, which scipy.signal.lfilter runs with coefficients b = [alpha], a = [1, alpha - 1]. Starting SciPy from lfilter_zi(b, a) * z[0] makes it begin at the first reading, as yours does. Two formulas to check: white noise of unit variance leaves α/(2−α)\alpha/(2-\alpha) of its variance, and on a ramp the output trails by (1−α)/α(1-\alpha)/\alpha samples, which with α=Δt/(τ+Δt)\alpha = \Delta t/(\tau + \Delta t) is exactly τ/Δt\tau/\Delta t.

== Step 3. First-order low-pass: vs scipy.signal.lfilter, noise left, delay on a ramp ==
fc Hz alpha max |ours - lfilter| noise std left sqrt(a/(2-a)) ramp lag samples tau/dt
50 0.3859 2.2e-16 0.4878 0.4889 1.59 1.59
10 0.1116 3.3e-16 0.2423 0.2431 7.96 7.96
2 0.0245 1.8e-15 0.1103 0.1114 39.79 39.79

The ramp lag is τ\tau to two decimals at every cutoff. The noise left is within about 1 % of the formula (the gap is the finite noise sample).

def central_difference(y, dt):
"""Velocity from samples: (y[k+1] - y[k-1]) / 2 dt inside, one-sided at the two ends."""
v = np.empty(len(y))
v[1:-1] = (y[2:] - y[:-2]) / (2 * dt)
v[0], v[-1] = (y[1] - y[0]) / dt, (y[-1] - y[-2]) / dt
return v

This is what np.gradient(y, dt) does, and the check confirms it to the last bit. Two readings with independent errors of 10.01 mrad, divided by 2Δt2\Delta t = 4 ms, predict the raw velocity noise:

== Step 4. Velocity by central differences ==
max |ours - np.gradient| = 0.0e+00 rad/s
raw velocity error: predicted sqrt(2) * 10.01 mrad / (2 dt) = 3.54 rad/s, measured 3.50 rad/s (true velocity RMS 0.63)

Predicted 3.54, measured 3.50 rad/s: the same 0.6 % low draw as Step 1. The noise is more than five times the true velocity, which is why velocity needs much heavier filtering than position.

Step 5 · Measure the delay and the errors

Section titled “Step 5 · Measure the delay and the errors”

The Experiment defines a filter’s delay as the shift that best lines the filtered signal up with the truth:

def delay_samples(y, ref, max_lag=400, skip=500):
"""The shift (in samples) that best aligns a filtered signal y with the true signal ref."""
errs = [np.mean((y[skip + lag:] - ref[skip:len(ref) - lag]) ** 2) for lag in range(max_lag)]
return int(np.argmin(errs))

Run every filter of the Experiment through your functions and compare with its numbers:

== Step 5. Every filter on the lesson's signal vs the Experiment ==
filter delay ms theory ms pos RMS mrad vel RMS rad/s max |ours - Exp.|
MA n=1 0.0 0.0 9.95 3.50 0.0e+00
MA n=3 2.0 2.0 5.91 1.65 0.0e+00
MA n=5 4.0 4.0 5.17 1.01 0.0e+00
MA n=9 8.0 8.0 6.03 0.55 0.0e+00
MA n=17 16.0 16.0 10.22 0.29 0.0e+00
MA n=33 32.0 32.0 19.76 0.16 0.0e+00
MA n=65 64.0 64.0 39.01 0.12 0.0e+00
MA n=129 128.0 128.0 76.52 0.19 0.0e+00
LPF 50 Hz 4.0 3.2 5.30 1.35 0.0e+00
LPF 20 Hz 8.0 8.0 6.02 0.71 0.0e+00
LPF 10 Hz 16.0 15.9 10.16 0.39 0.0e+00
LPF 2 Hz 78.0 79.6 47.33 0.14 0.0e+00
LPF 0.5 Hz 274.0 318.3 152.59 0.35 0.0e+00
lag error of MA n=17: speed x delay = 0.61 rad/s x 16 ms = 9.8 mrad

Every row equals the Experiment table, to the last digit. The measured delays match (n−1)Δt/2(n-1)\Delta t/2 for every window. For the low-pass they match τ\tau only while fcf_c is well above the motion (delays are whole 2 ms samples, so the 50 Hz row shows 3.2 ms as 4.0; the 0.5 Hz row is 274 ms, not 318.3 ms). The last line is the lag error from Core idea for MA n=17: 9.8 mrad of the 10.22 measured.

physai.estimation now has encoder_reading, moving_average, low_pass, central_difference and delay_samples. Lesson 18 uses the same encoder model and moving_average as its baseline and replaces both filters with one that predicts; lesson 37 comes back to delay inside a feedback loop. tests/test_17_sensors_noise_filtering.py pins them to SciPy; run uv run pytest tests/test_17_sensors_noise_filtering.py after you change anything.

SciPy runs both filters as one call, scipy.signal.lfilter(b, a, x), the difference equation a0yk=∑bixk−i−∑j≥1ajyk−ja_0 y_k = \sum b_i x_{k-i} - \sum_{j\ge1} a_j y_{k-j}:

from scipy.signal import group_delay, lfilter, lfilter_zi
y_ma = lfilter(np.ones(n) / n, 1.0, z) # moving average (first n-1 outputs differ)
alpha = dt / (1 / (2 * np.pi * fc) + dt)
b, a = [alpha], [1.0, alpha - 1.0]
y_lp, _ = lfilter(b, a, z, zi=lfilter_zi(b, a) * z[0]) # low-pass, starting at the first reading
_, gd = group_delay((b, a), w=[0.2], fs=1 / dt) # delay in samples at 0.2 Hz

group_delay gives the delay from the coefficients alone, without a signal: (n−1)/2(n-1)/2 samples for the moving average and τ/Δt\tau/\Delta t at low frequency for the low-pass, both checked in the tests. Other tools order and place the same window differently:

CallWindowStart-up
physai moving_average(x, n)last nn samples (causal)mean of the samples seen so far
scipy.signal.lfilter(np.ones(n)/n, 1, x)last nn samples (causal)samples before the start count as 0 (“initial rest”)
scipy.ndimage.uniform_filter1d(x, n)centred on the sample (origin=0), uses the futuremode='reflect' at the edges; origin=(n-1)//2 makes it causal (checked in the tests)
np.gradient(y, dt)one sample before and after (central)one-sided differences at both ends

Sources: lfilter, group_delay, uniform_filter1d, np.gradient.

Three traps:

  • A centred filter looks free. uniform_filter1d and np.gradient use samples from the future: offline they show zero delay, in a running controller those samples do not exist yet. The Experiment’s velocity uses np.gradient and so peeks one sample ahead.
  • “First-order low-pass at fcf_c” is not one filter. scipy.signal.butter(1, 10, fs=500) returns b = [0.0592, 0.0592], a = [1, -0.8816] (bilinear transform); this lesson’s α\alpha gives a = [1, -0.8884]. Both are called a 10 Hz first-order low-pass.
  • The noise attribute adds nothing. MuJoCo stores it as metadata; the noise in this lesson is the one you add in Step 1.

Check after building · 2 questions.

1. In Build it, Step 3, the 10 Hz low-pass trails a ramp by 7.96 samples. Where does that number come from?

  • It is half the filter’s window, like a moving average of 17 samples.
  • It is the start-up transient, which has not died out yet.
  • On a ramp the filter trails by (1−α)/α(1-\alpha)/\alpha samples, and with α=Δt/(τ+Δt)\alpha = \Delta t/(\tau + \Delta t) that is exactly τ/Δt\tau/\Delta t = 15.9 ms / 2 ms.

Why: Step 3 prints ramp lag 7.96 samples against τ/Δt\tau/\Delta t = 7.96 at 10 Hz (and 1.59, 39.79 at 50 and 2 Hz). A low-pass has no window; its delay is its time constant.

2. You swap your causal moving average for scipy.ndimage.uniform_filter1d(x, n) with its defaults. Offline the delay drops to zero. Will your controller see that too?

  • Yes; SciPy’s implementation is simply faster.
  • No; the default window is centred on the sample and uses future readings, which a running controller does not have yet.
  • Yes, as long as the window length nn is odd.

Why: Use it - uniform_filter1d is centred (origin=0); origin=(n-1)//2 makes it causal, and then it has the same (n−1)/2(n-1)/2-sample delay as Step 2.

Setup. SO-101 from MuJoCo Menagerie, timestep 2 ms (500 Hz sampling). shoulder_pan follows 0.5sin⁡(2π 0.2t)+0.2sin⁡(2π 0.5t)0.5\sin(2\pi\,0.2t) + 0.2\sin(2\pi\,0.5t) rad through its position actuator for 10 s. We model a 12-bit encoder (4096 counts/rev, step 1.534 mrad) plus white noise σ=10\sigma = 10 mrad. These are teaching values, not datasheet values. Velocity is computed with np.gradient (central difference) of the filtered signal. Delay is the shift that best aligns the filtered and true signals.

s = spec.add_sensor(name="q_pan", type=mujoco.mjtSensor.mjSENS_JOINTPOS,
objname="shoulder_pan", objtype=mujoco.mjtObj.mjOBJ_JOINT)
s.noise = 0.01 # metadata only
...
z = np.round(q / step) * step + rng.normal(0, SIGMA, q.shape) # our encoder
y = moving_average(z, n) # or low_pass(z, fc)

Predict. Raw velocity noise should be about 2 σ/(2Δt)≈3.5\sqrt2\,\sigma/(2\Delta t) \approx 3.5 rad/s, larger than the true velocity. The best position window should be short, the best velocity window much longer.

Run uv run python experiments/17-sensors-noise-filtering/experiment.py (MuJoCo 3.17.0, NumPy 2.5.4, Apple M5, deterministic).

First checks: the jointpos sensor declared with noise=0.01 differs from qpos by exactly 0.0 rad. (Sensors are computed at the start of mj_step, from the state before integration; compare against that state or you see a one-step offset.) The raw reading has position RMS error 9.95 mrad and velocity RMS error 3.50 rad/s, against a true velocity RMS of 0.63 rad/s.

Position and velocity error versus filter delay for the moving average and the low-pass filter. Position error is lowest near 4 ms of delay, velocity error near 64 ms.

FilterDelay (ms)Position RMS (mrad)Velocity RMS (rad/s)
raw0.09.953.50
MA n=32.05.911.65
MA n=54.05.171.01
MA n=98.06.030.55
MA n=1716.010.220.29
MA n=3332.019.760.16
MA n=6564.039.010.12
MA n=129128.076.520.19
LPF 50 Hz4.05.301.35
LPF 20 Hz8.06.020.71
LPF 10 Hz16.010.160.39
LPF 2 Hz78.047.330.14
LPF 0.5 Hz274.0152.590.35

Observe.

  1. The moving-average delay equals (n−1)Δt/2(n-1)\Delta t/2 in every row. The low-pass delay matches τ\tau (15.9 → 16.0 ms at 10 Hz) until fcf_c approaches the signal frequencies: at 0.5 Hz it is 274 ms, not 318.3 ms, because τ\tau is only the low-frequency limit.
  2. Position error is lowest at about 4 ms of delay (MA n=5, 5.17 mrad) and grows almost linearly beyond it: from 17 samples on, the filter is worse than no filter at all.
  3. Velocity needs 16 times the delay (MA n=65, 64 ms, 0.12 rad/s). One filter cannot serve both. This is the gap the Kalman filter of lesson 18 closes.
  4. At equal delay, the two filter types perform almost the same (MA n=9: 6.03 mrad; LPF 20 Hz: 6.02 mrad at 8 ms). The delay you accept matters more than the filter’s shape.

The IMU check (same script): the arm at rest reads ∣a∣=9.810|a| = 9.810 m/s² on the accelerometer. A gyro with a 0.02 rad/s bias and 0.05 rad/s noise, integrated for 10 s, reports a rotation of 0.209 rad (12.0°) for a joint that did not move.

Keep MA n=17 (16 ms delay) and make the motion faster by scaling both frequencies by kk:

kTrue velocity RMS (rad/s)Raw position RMS (mrad)MA17 position RMS (mrad)
0.50.289.945.01
10.619.9510.22
21.279.9520.46
42.519.9540.00
83.739.9459.49

The raw error does not care about speed. The filtered error follows speed × delay: 0.61 rad/s × 16 ms ≈ 9.8 mrad versus 10.22 measured. A window tuned on a slow test move becomes worse than no filter on a fast move. Tune filters on the fastest motion the robot will actually perform, or use a model-based filter that predicts the motion instead of averaging it (lesson 18).

  • The SO-101 follower uses six Feetech STS3215 servos with 1/345 gearing (LeRobot SO-101 docs). Position comes from the servo’s internal encoder over the serial bus; there is no velocity sensor and no IMU in the Menagerie model. We did not verify the STS3215 encoder resolution; the 4096 counts above is our modelling choice.
  • On real hardware the read rate is limited by the bus, not by a 2 ms timestep, so Δt\Delta t and the delays above change. Recompute them for your loop rate.
  • Measuring your own σ\sigma: hold the arm in a fixed pose, log every joint for 10 s, and take the standard deviation per joint. A pure quantizer at rest may show zero variance; command a slow ramp and look at the residual from a fitted line instead. Not yet measured on the real arm.

Quick check · 2 questions. Click an answer.

1. A 17-sample moving average leaves only σ/17\sigma/\sqrt{17} of the noise. Why is its position error (10.22 mrad) worse than the raw reading (9.95 mrad)?

  • Averaging quantized readings adds extra quantization error.
  • Its 16 ms delay makes it lag by about speed × delay, more than the noise it removes.
  • The noise is correlated, so averaging does not reduce it.

Why: Lag error ≈ 0.61 rad/s × 16 ms ≈ 9.8 mrad (Break it). The sum of noise and lag is lowest at about 4 ms of delay, MA n=5 with 5.17 mrad.

2. You tuned an MA n=17 on a slow test move. The real task moves 4 times faster. What happens to the position error?

  • It grows to about 40 mrad, four times the raw reading’s error.
  • It stays the same, because the noise does not depend on speed.
  • It halves, because faster motion averages out the noise.

Why: Break it, k = 4 - MA17 gives 40.00 mrad while the raw error stays at 9.95 mrad. Tune filters on the fastest motion the robot will actually perform.

  1. Easy. A 64-sample moving average runs at 200 Hz. What is its delay?
  2. Medium. In the table, why is the best velocity window (65) much longer than the best position window (5)? Use the “dividing noise by Δt” argument.
  3. Hard. Replace np.gradient with a causal backward difference (yk−yk−1)/Δt(y_k - y_{k-1})/\Delta t. Predict the raw velocity noise, then run it. Does the best velocity window change?
  1. (64−1)/2×5(64-1)/2 \times 5 ms =157.5= 157.5 ms.
  2. Differentiating multiplies the remaining noise by about 1/Δt=5001/\Delta t = 500 s⁻¹, while the lag term only changes from speed × delay to acceleration × delay. The noise term is far larger for velocity than for position, so the minimum of noise + lag moves to a much longer delay.
  3. Predicted raw noise 2 σ/Δt≈7.1\sqrt2\,\sigma/\Delta t \approx 7.1 rad/s, twice the central-difference value, because the central difference spans two sample intervals. Run it to see the new optimum; we did not.
  • MuJoCo sensors are exact; noise is metadata. Quantization, white noise and gyro bias are yours to add.
  • A filter’s delay is predictable (n−12Δt\frac{n-1}{2}\Delta t or ≈τ\approx\tau) and its lag error is about speed × delay. The best window depends on how fast the joint moves, and it is different for position and for velocity.
  • Integrating a biased gyro drifts linearly; you need a second sensor or a model to correct it.

Next: lesson 18, the Kalman filter.

Sources: