Objective and use case
What you’ll build: A real-time vibration anomaly detector using a Raspberry Pi 4 and Pimoroni ICM-20948 IMU to collect and analyze vibration data.
Why it matters / Use cases
- Monitor industrial machinery to detect early signs of mechanical failure, reducing downtime and maintenance costs.
- Implement in smart home systems to ensure structural integrity by detecting abnormal vibrations in buildings.
- Use in automotive applications to analyze vibrations in vehicles for improved safety and performance.
- Integrate with IoT systems to provide real-time alerts for equipment anomalies in remote locations.
Expected outcome
- Detect and flag abnormal vibration patterns with a latency of less than 1 second.
- Achieve over 90% accuracy in anomaly detection based on frequency-domain features.
- Process data at a rate of 100 packets/s from the ICM-20948 IMU.
- Generate alerts for anomalies with a clear message indicating the type of vibration detected.
Audience: Intermediate developers; Level: Advanced
Architecture/flow: Data collection from ICM-20948 IMU → Signal processing → Anomaly detection model → Real-time alerting
Raspberry Pi 4 Model B + Pimoroni ICM-20948 9DoF IMU: Vibration Anomaly Detection (Advanced)
This hands-on project builds a complete real-time vibration anomaly detector using a Raspberry Pi 4 Model B running Raspberry Pi OS Bookworm 64-bit (Python 3.11) and a Pimoroni ICM-20948 9DoF IMU. You will collect IMU data, compute frequency-domain features, train an unsupervised anomaly model, and run live detection to flag abnormal vibration patterns.
The guide assumes you are comfortable with Linux shell, Python, and basic signal processing. No circuit drawings are used; all connections, commands, and code are precisely described in text, tables, and blocks.
Prerequisites
- Raspberry Pi OS Bookworm 64-bit installed on your Raspberry Pi 4 Model B.
- Internet access on the Pi.
- Terminal access (local or SSH).
- Basic knowledge of Python virtual environments and command-line usage.
OS and Python defaults:
– Raspberry Pi OS Bookworm (64-bit)
– Python 3.11 (default python3 on Bookworm)
Materials (exact models)
- Raspberry Pi 4 Model B (any RAM size).
- Pimoroni ICM-20948 9DoF IMU breakout (I2C mode).
- Jumper wires (female-female if using Pi header and breakout).
- Optional: Qwiic/STEMMA QT cable if your Pimoroni board supports JST-SH connectors.
- Stable 5V/3A USB-C power supply for the Raspberry Pi 4 Model B.
- A vibrating source for validation (e.g., a small DC motor, an electric toothbrush, or a vibration motor).
- Mounting tape or zip ties to secure the IMU to the vibrating object.
Setup/Connection
1) Enable I2C interface
You can enable I2C either interactively or by editing the config file.
Option A: raspi-config (non-interactive or interactive)
– Interactive:
1. Run: sudo raspi-config
2. Interface Options → I2C → Enable.
3. Finish and reboot.
- Non-interactive:
sudo raspi-config nonint do_i2c 0
sudo reboot
Option B: Edit config file
– Add/ensure the following lines exist:
sudoedit /boot/firmware/config.txt
Append:
dtparam=i2c_arm=on
dtparam=i2c_arm_baudrate=400000
Then:
sudo reboot
After reboot, add your user to the i2c group:
sudo adduser $USER i2c
newgrp i2c
Verify the I2C bus:
ls -l /dev/i2c-1
2) Wiring the Pimoroni ICM-20948 to the Pi (I2C)
Use 3.3V logic only. The ICM-20948 default I2C address is 0x68 (0x69 if the address pad is configured accordingly on the breakout).
Connection map:
| Raspberry Pi 4 Model B Pin | Signal | IMU Pin (Pimoroni ICM-20948) |
|---|---|---|
| Physical pin 1 | 3V3 | 3V3 |
| Physical pin 6 | GND | GND |
| Physical pin 3 (GPIO2) | SDA1 | SDA |
| Physical pin 5 (GPIO3) | SCL1 | SCL |
- Keep wires short and secure.
- Ensure common ground between Pi and IMU.
- If your Pimoroni board has a Qwiic connector, you may use a Qwiic cable to the Pi via a Qwiic-to-Pi shim; otherwise, wire as above.
Check device detection:
sudo apt update
sudo apt install -y i2c-tools
i2cdetect -y 1
You should see “68” (or “69”) in the output grid.
Full Code
Below is a single-file, advanced script implementing:
– A minimal ICM-20948 I2C driver (accelerometer + gyroscope)
– Baseline collection
– Feature extraction (time and frequency domain)
– Model training (Isolation Forest + StandardScaler)
– Real-time anomaly detection
Save as:
– /home/pi/projects/imu-vibe-anomaly/imu_vibe.py
Create the directory first:
mkdir -p /home/pi/projects/imu-vibe-anomaly/data
Then create imu_vibe.py with the content below:
#!/usr/bin/env python3
# Python 3.11, Raspberry Pi OS Bookworm 64-bit
import os
import sys
import time
import math
import json
import argparse
import struct
import csv
from dataclasses import dataclass
from typing import Tuple, List
import numpy as np
from smbus2 import SMBus, i2c_msg
# Optional heavy imports are gated by mode
# pip: numpy==1.26.4 scipy==1.11.4 scikit-learn==1.3.2 joblib==1.3.2
try:
from scipy.signal import welch
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
import joblib
except Exception:
# In modes that don't need ML, we ignore; validate before run
pass
# ICM-20948 register map (subset) and configuration constants
ICM20948_I2C_ADDR = 0x68 # default; 0x69 if AD0/ADDR pad pulled
# Common registers (Bank 0)
REG_BANK_SEL = 0x7F # bank select register
BANK0 = 0x00
BANK2 = 0x20
WHO_AM_I = 0x00 # should read 0xEA
USER_CTRL = 0x03
LP_CONFIG = 0x05
PWR_MGMT_1 = 0x06
PWR_MGMT_2 = 0x07
INT_PIN_CFG = 0x0F
# Output registers (Bank 0)
ACCEL_XOUT_H = 0x2D
ACCEL_XOUT_LEN = 6
GYRO_XOUT_H = 0x33
GYRO_XOUT_LEN = 6
# Bank 2 registers for configuration
GYRO_SMPLRT_DIV = 0x00
GYRO_CONFIG_1 = 0x01
# GYRO_CONFIG_2 = 0x02 # not used here
ACCEL_SMPLRT_DIV_1 = 0x10
ACCEL_SMPLRT_DIV_2 = 0x11
ACCEL_CONFIG = 0x14
# ACCEL_CONFIG_2 = 0x15 # not used here
WHO_AM_I_EXPECTED = 0xEA
@dataclass
class IMUScales:
accel_fs_g: int # ±g range
gyro_fs_dps: int # ±dps range
accel_lsb_per_g: float # counts per g
gyro_lsb_per_dps: float # counts per dps
class ICM20948:
"""
Minimal I2C driver for Pimoroni ICM-20948 (Accel + Gyro).
Magnetometer not used in this project.
"""
def __init__(self, i2c_bus: int = 1, addr: int = ICM20948_I2C_ADDR, i2c_baud: int = 400000):
self.addr = addr
self.bus = SMBus(i2c_bus)
# Scale defaults (will be set during configure)
self.scales = IMUScales(accel_fs_g=16, gyro_fs_dps=2000,
accel_lsb_per_g=2048.0, gyro_lsb_per_dps=16.4)
def _select_bank(self, bank: int):
self._write_u8(REG_BANK_SEL, bank & 0x30) # valid bank bits: [5:4]
def _write_u8(self, reg: int, val: int):
self.bus.write_byte_data(self.addr, reg, val & 0xFF)
def _read_u8(self, reg: int) -> int:
return self.bus.read_byte_data(self.addr, reg)
def _read_block(self, reg: int, length: int) -> bytes:
read = i2c_msg.read(self.addr, length)
self.bus.i2c_rdwr(i2c_msg.write(self.addr, [reg]), read)
return bytes(read)
def who_am_i(self) -> int:
self._select_bank(BANK0)
return self._read_u8(WHO_AM_I)
def reset_and_wake(self):
self._select_bank(BANK0)
# Clear sleep, select auto clock (bit0=1), others default
self._write_u8(PWR_MGMT_1, 0x01)
time.sleep(0.05)
# Enable accel + gyro (clear disables)
self._write_u8(PWR_MGMT_2, 0x00)
time.sleep(0.01)
# Optional: route INT pin, bypass not needed for this project
self._write_u8(INT_PIN_CFG, 0x00)
def configure(self, accel_fs_g: int = 16, gyro_fs_dps: int = 2000,
dlpf_accel: int = 3, dlpf_gyro: int = 3,
sample_rate_hz: int = 200):
"""
Configure full-scale ranges, DLPF, and sample rate.
dlpf_*: 0..7 (lower => more filtering). 3 is a reasonable default.
sample_rate_hz: target sample rate for both accel and gyro.
"""
self._select_bank(BANK2)
# Gyro full-scale config (GYRO_CONFIG_1)
# FS_SEL bits [2:1]: 00=250, 01=500, 10=1000, 11=2000 dps
gyro_fs_bits = {250:0, 500:1, 1000:2, 2000:3}[gyro_fs_dps] << 1
gyro_dlpfcfg = (dlpf_gyro & 0x07)
gyro_cfg1 = gyro_fs_bits | gyro_dlpfcfg
self._write_u8(GYRO_CONFIG_1, gyro_cfg1)
# Gyro sample rate: f_sample = 1100 / (1 + GYRO_SMPLRT_DIV) for DLPF enabled
# Use an integer divider to approximate sample_rate_hz
# We want ~200 Hz by default
gyro_div = max(0, int(round(1100 / max(1, sample_rate_hz) - 1)))
self._write_u8(GYRO_SMPLRT_DIV, gyro_div)
# Accel full-scale config (ACCEL_CONFIG)
# FS_SEL bits [2:1]: 00=2g, 01=4g, 10=8g, 11=16g
accel_fs_bits = {2:0, 4:1, 8:2, 16:3}[accel_fs_g] << 1
accel_dlpfcfg = (dlpf_accel & 0x07)
accel_cfg = accel_fs_bits | accel_dlpfcfg
self._write_u8(ACCEL_CONFIG, accel_cfg)
# Accel sample rate: f_sample = 1125 / (1 + ACCEL_SMPLRT_DIV)
# Div is 12-bit across two regs: DIV_1 (upper), DIV_2 (lower)
accel_div = max(0, int(round(1125 / max(1, sample_rate_hz) - 1)))
self._write_u8(ACCEL_SMPLRT_DIV_1, (accel_div >> 8) & 0x0F)
self._write_u8(ACCEL_SMPLRT_DIV_2, accel_div & 0xFF)
# Update conversion scales based on selected full-scales
accel_lsb_per_g_map = {2:16384.0, 4:8192.0, 8:4096.0, 16:2048.0}
gyro_lsb_per_dps_map = {250:131.0, 500:65.5, 1000:32.8, 2000:16.4}
self.scales = IMUScales(
accel_fs_g=accel_fs_g,
gyro_fs_dps=gyro_fs_dps,
accel_lsb_per_g=accel_lsb_per_g_map[accel_fs_g],
gyro_lsb_per_dps=gyro_lsb_per_dps_map[gyro_fs_dps],
)
self._select_bank(BANK0)
def read_accel_gyro(self) -> Tuple[Tuple[float,float,float], Tuple[float,float,float]]:
"""
Read accelerometer and gyroscope; return values in g and dps.
"""
# Read accel (6 bytes) then gyro (6 bytes)
self._select_bank(BANK0)
accel_bytes = self._read_block(ACCEL_XOUT_H, ACCEL_XOUT_LEN)
gyro_bytes = self._read_block(GYRO_XOUT_H, GYRO_XOUT_LEN)
ax, ay, az = struct.unpack(">hhh", accel_bytes)
gx, gy, gz = struct.unpack(">hhh", gyro_bytes)
ax_g = ax / self.scales.accel_lsb_per_g
ay_g = ay / self.scales.accel_lsb_per_g
az_g = az / self.scales.accel_lsb_per_g
gx_dps = gx / self.scales.gyro_lsb_per_dps
gy_dps = gy / self.scales.gyro_lsb_per_dps
gz_dps = gz / self.scales.gyro_lsb_per_dps
return (ax_g, ay_g, az_g), (gx_dps, gy_dps, gz_dps)
def magnitude3(v: Tuple[float,float,float]) -> float:
return math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
def extract_features(accel_window: np.ndarray, gyro_window: np.ndarray, fs: float) -> np.ndarray:
"""
Compute a compact feature vector capturing vibration signatures.
Inputs are Nx3 arrays (columns: x,y,z).
"""
# Magnitudes to reduce orientation dependency
a_mag = np.linalg.norm(accel_window, axis=1)
g_mag = np.linalg.norm(gyro_window, axis=1)
# Time-domain stats
def stats(x):
return np.array([
np.mean(x), np.std(x), np.sqrt(np.mean(x*x)), # mean, std, RMS
np.max(x) - np.min(x), # peak-to-peak
np.median(x),
np.percentile(x, 95) - np.percentile(x, 5), # robust range
], dtype=np.float64)
a_stats = stats(a_mag)
g_stats = stats(g_mag)
# Frequency-domain (Welch PSD on accel magnitude)
f, Pxx = welch(a_mag, fs=fs, nperseg=min(256, len(a_mag)))
Pxx = Pxx + 1e-16 # avoid log/0
# Band powers
def bandpower(f, Pxx, fmin, fmax):
idx = np.logical_and(f >= fmin, f < fmax)
return np.trapz(Pxx[idx], f[idx]) if np.any(idx) else 0.0
bp_0_10 = bandpower(f, Pxx, 0.0, 10.0)
bp_10_50 = bandpower(f, Pxx, 10.0, 50.0)
bp_50_100 = bandpower(f, Pxx, 50.0, 100.0)
# Spectral centroid and dominant frequency
centroid = np.sum(f * Pxx) / np.sum(Pxx)
dom_idx = np.argmax(Pxx)
dom_freq = f[dom_idx]
dom_amp = Pxx[dom_idx]
features = np.concatenate([
a_stats, g_stats,
np.array([bp_0_10, bp_10_50, bp_50_100, centroid, dom_freq, dom_amp])
])
return features
def collect_samples(imu: ICM20948, fs: float, seconds: float, bias_cal: dict | None) -> Tuple[np.ndarray, np.ndarray]:
"""
Collect accel/gyro samples for `seconds` at rate `fs`.
Returns arrays shape (N,3) for accel and gyro.
Applies optional bias calibration.
"""
dt = 1.0 / fs
N = int(seconds * fs)
accel = np.zeros((N, 3), dtype=np.float64)
gyro = np.zeros((N, 3), dtype=np.float64)
next_t = time.perf_counter()
for i in range(N):
a, g = imu.read_accel_gyro()
ax, ay, az = a
gx, gy, gz = g
if bias_cal:
ax -= bias_cal.get("accel_bias_x", 0.0)
ay -= bias_cal.get("accel_bias_y", 0.0)
az -= bias_cal.get("accel_bias_z", 0.0)
gx -= bias_cal.get("gyro_bias_x", 0.0)
gy -= bias_cal.get("gyro_bias_y", 0.0)
gz -= bias_cal.get("gyro_bias_z", 0.0)
accel[i, :] = (ax, ay, az)
gyro[i, :] = (gx, gy, gz)
next_t += dt
# Sleep to maintain rate; compensate for read time
rem = next_t - time.perf_counter()
if rem > 0:
time.sleep(rem)
return accel, gyro
def calibrate_bias(imu: ICM20948, fs: float = 200.0, seconds: float = 5.0) -> dict:
"""
Estimate simple biases by averaging samples while device is stationary.
Note: accelerometer includes gravity; we compute mean per axis so magnitude-based features are less affected.
"""
accel, gyro = collect_samples(imu, fs=fs, seconds=seconds, bias_cal=None)
a_bias = np.mean(accel, axis=0)
g_bias = np.mean(gyro, axis=0)
return {
"accel_bias_x": float(a_bias[0]),
"accel_bias_y": float(a_bias[1]),
"accel_bias_z": float(a_bias[2]),
"gyro_bias_x": float(g_bias[0]),
"gyro_bias_y": float(g_bias[1]),
"gyro_bias_z": float(g_bias[2]),
"fs": fs
}
def sliding_windows(data_a: np.ndarray, data_g: np.ndarray, win: int, step: int):
"""
Generator of overlapping windows.
"""
N = data_a.shape[0]
for start in range(0, N - win + 1, step):
end = start + win
yield data_a[start:end, :], data_g[start:end, :]
def save_csv_features(path: str, features: List[np.ndarray]):
with open(path, "w", newline="") as f:
w = csv.writer(f)
for feat in features:
w.writerow([float(x) for x in feat])
def load_csv_features(path: str) -> np.ndarray:
rows = []
with open(path, "r", newline="") as f:
r = csv.reader(f)
for row in r:
rows.append([float(x) for x in row])
return np.array(rows, dtype=np.float64)
def cmd_whoami(args):
imu = ICM20948(i2c_bus=1, addr=args.addr)
ident = imu.who_am_i()
print(f"ICM-20948 WHO_AM_I: 0x{ident:02X}")
if ident != WHO_AM_I_EXPECTED:
print("WARNING: Unexpected WHO_AM_I. Check address and wiring.", file=sys.stderr)
def cmd_collect_baseline(args):
imu = ICM20948(i2c_bus=1, addr=args.addr)
ident = imu.who_am_i()
if ident != WHO_AM_I_EXPECTED:
print(f"WHO_AM_I mismatch: 0x{ident:02X} != 0x{WHO_AM_I_EXPECTED:02X}", file=sys.stderr)
imu.reset_and_wake()
imu.configure(accel_fs_g=16, gyro_fs_dps=2000, dlpf_accel=3, dlpf_gyro=3, sample_rate_hz=args.fs)
print("Calibrating biases... keep IMU still")
bias = calibrate_bias(imu, fs=args.fs, seconds=5.0)
print("Bias:", bias)
os.makedirs(args.outdir, exist_ok=True)
with open(os.path.join(args.outdir, "bias.json"), "w") as f:
json.dump(bias, f, indent=2)
print(f"Collecting baseline for {args.seconds:.1f}s at {args.fs:.1f} Hz...")
accel, gyro = collect_samples(imu, fs=args.fs, seconds=args.seconds, bias_cal=bias)
# Windowing
win = int(args.fs * args.win_s)
step = int(win // 2) # 50% overlap
feats = []
for a_w, g_w in sliding_windows(accel, gyro, win, step):
feats.append(extract_features(a_w, g_w, fs=args.fs))
feat_path = os.path.join(args.outdir, "baseline_features.csv")
save_csv_features(feat_path, feats)
print(f"Saved baseline features: {feat_path}, windows: {len(feats)}")
def cmd_train(args):
feat_path = os.path.join(args.outdir, "baseline_features.csv")
X = load_csv_features(feat_path)
print(f"Loaded baseline features: {X.shape}")
scaler = StandardScaler()
Xs = scaler.fit_transform(X)
# Unsupervised anomaly detector
model = IsolationForest(
n_estimators=200,
contamination=args.contamination,
random_state=42,
n_jobs=-1
)
model.fit(Xs)
joblib.dump(scaler, os.path.join(args.outdir, "scaler.pkl"))
joblib.dump(model, os.path.join(args.outdir, "iforest.pkl"))
print(f"Saved scaler and model in {args.outdir}")
def cmd_detect(args):
imu = ICM20948(i2c_bus=1, addr=args.addr)
imu.reset_and_wake()
imu.configure(accel_fs_g=16, gyro_fs_dps=2000, dlpf_accel=3, dlpf_gyro=3, sample_rate_hz=args.fs)
with open(os.path.join(args.outdir, "bias.json"), "r") as f:
bias = json.load(f)
scaler = joblib.load(os.path.join(args.outdir, "scaler.pkl"))
model = joblib.load(os.path.join(args.outdir, "iforest.pkl"))
win = int(args.fs * args.win_s)
step = int(win // 2)
print("Starting live detection. Press Ctrl+C to stop.")
buf_a = np.zeros((0, 3), dtype=np.float64)
buf_g = np.zeros((0, 3), dtype=np.float64)
try:
while True:
a, g = collect_samples(imu, fs=args.fs, seconds=args.win_s/2, bias_cal=bias) # half-window chunk
buf_a = np.vstack([buf_a, a])
buf_g = np.vstack([buf_g, g])
# Slide windows across the buffer
idx = 0
while idx + win <= buf_a.shape[0]:
a_w = buf_a[idx:idx+win, :]
g_w = buf_g[idx:idx+win, :]
feat = extract_features(a_w, g_w, fs=args.fs).reshape(1, -1)
feat_s = scaler.transform(feat)
pred = model.predict(feat_s)[0] # 1=normal, -1=anomaly
score = model.score_samples(feat_s)[0] # higher is more normal
status = "ANOMALY" if pred == -1 else "normal "
tstamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"{tstamp} {status} score={score:.3f}")
idx += step
# Keep only the last (win - step) samples to maintain overlap
if buf_a.shape[0] > win:
buf_a = buf_a[-(win - step):, :]
buf_g = buf_g[-(win - step):, :]
except KeyboardInterrupt:
print("\nStopped.")
def cmd_configure_only(args):
imu = ICM20948(i2c_bus=1, addr=args.addr)
imu.reset_and_wake()
imu.configure(accel_fs_g=16, gyro_fs_dps=2000, dlpf_accel=3, dlpf_gyro=3, sample_rate_hz=args.fs)
print("Configured IMU at ~{} Hz, FS: ±{}g / ±{} dps".format(args.fs, 16, 2000))
def main():
parser = argparse.ArgumentParser(description="ICM-20948 Vibration Anomaly Detection")
parser.add_argument("--addr", type=lambda x: int(x, 0), default=0x68, help="I2C address (0x68 or 0x69)")
parser.add_argument("--fs", type=float, default=200.0, help="Target sample rate (Hz, default 200)")
parser.add_argument("--outdir", type=str, default="/home/pi/projects/imu-vibe-anomaly/data")
parser.add_argument("--win-s", type=float, default=2.0, help="Window size in seconds (default 2.0)")
sub = parser.add_subparsers(dest="mode", required=True)
sub.add_parser("whoami")
sub.add_parser("configure")
p_collect = sub.add_parser("collect-baseline")
p_collect.add_argument("--seconds", type=float, default=60.0, help="Baseline collection duration in seconds")
p_train = sub.add_parser("train")
p_train.add_argument("--contamination", type=float, default=0.05, help="Expected anomaly fraction (0..0.5)")
sub.add_parser("detect")
args = parser.parse_args()
if args.mode == "whoami":
cmd_whoami(args)
elif args.mode == "configure":
cmd_configure_only(args)
elif args.mode == "collect-baseline":
cmd_collect_baseline(args)
elif args.mode == "train":
cmd_train(args)
elif args.mode == "detect":
cmd_detect(args)
else:
print("Unknown mode")
sys.exit(1)
if __name__ == "__main__":
main()
Notes:
– The driver configures accelerometer and gyro to ±16g and ±2000 dps with DLPF=3 and a target sample rate of ~200 Hz.
– WHO_AM_I expected value for ICM-20948 is 0xEA.
– For demanding vibration capture, consider using the sensor FIFO or SPI. Here we target 200 Hz with I2C at 400 kHz for simplicity.
Build/Flash/Run commands
There is nothing to “flash,” but you will set up a Python 3.11 virtual environment, install dependencies, and run the script.
1) System packages and Python venv
sudo apt update
sudo apt install -y python3-venv python3-dev python3-pip i2c-tools git libatlas-base-dev
Create and activate a virtual environment:
python3 -m venv /home/pi/venvs/imu-venv
source /home/pi/venvs/imu-venv/bin/activate
python -V # Should show Python 3.11.x
pip -V # pip for the venv
Upgrade pip and install Python packages (pin versions for reproducibility):
pip install --upgrade pip
pip install numpy==1.26.4 scipy==1.11.4 scikit-learn==1.3.2 joblib==1.3.2 smbus2==0.4.3 matplotlib==3.8.2
Project directory and script:
mkdir -p /home/pi/projects/imu-vibe-anomaly/data
nano /home/pi/projects/imu-vibe-anomaly/imu_vibe.py
# Paste the Full Code above, save with Ctrl+O, Enter, exit with Ctrl+X
chmod +x /home/pi/projects/imu-vibe-anomaly/imu_vibe.py
2) Validate I2C presence and WHO_AM_I
i2cdetect -y 1
# Expect to see 68 (or 69)
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --addr 0x68 whoami
If your board is configured as 0x69, pass --addr 0x69.
3) Configure the IMU (sanity check)
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --fs 200 configure
This ensures the IMU is awake and set to desired ranges.
4) Collect baseline features (normal vibration)
Mount the IMU on your target machine in a known “normal” state. The baseline should represent healthy operation.
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --fs 200 --win-s 2.0 --outdir /home/pi/projects/imu-vibe-anomaly/data collect-baseline --seconds 60
Generated files:
– /home/pi/projects/imu-vibe-anomaly/data/bias.json
– /home/pi/projects/imu-vibe-anomaly/data/baseline_features.csv
5) Train the anomaly model
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --outdir /home/pi/projects/imu-vibe-anomaly/data train --contamination 0.05
Artifacts:
– scaler.pkl
– iforest.pkl
6) Run real-time anomaly detection
Attach the IMU to the machine. Intentionally induce an abnormal vibration (e.g., add a small imbalance or press an object lightly onto the rotating part) to see detection transitions.
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --fs 200 --win-s 2.0 --outdir /home/pi/projects/imu-vibe-anomaly/data detect
Observe printed lines with timestamps, “normal” or “ANOMALY,” and a score.
Step-by-step Validation
1) Hardware wiring check
– Confirm connections match the table (3V3, GND, SDA, SCL).
– Ensure solid mechanical mounting to capture realistic vibrations (tape or zip ties).
2) I2C device enumerates
– Run:
i2cdetect -y 1
Expect 68 (or 69) in the map.
3) WHO_AM_I matches
– Run:
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --addr 0x68 whoami
Output should include “ICM-20948 WHO_AM_I: 0xEA”.
4) IMU configuration applies
– Run:
python /home/pi/projects/imu-vibe-anomaly/imu_vibe.py --fs 200 configure
You should see a confirmation line of configuration.
5) Bias calibration while stationary
– During baseline collection, keep the IMU still for the first 5 seconds while it calibrates biases. The command prints mean biases; values are typically small (gyro near 0 dps; accelerometer around gravity on one axis).
6) Baseline capture (healthy)
– With the machine in a healthy state, run the baseline collection for 60 seconds (or more for richer data). The script computes overlapping window features and writes baseline_features.csv. Check the number of windows reported; for fs=200 Hz, win=2.0 s, step=1.0 s, ~59 windows for 60 s.
7) Model training
– After training, confirm scaler.pkl and iforest.pkl exist:
ls -lh /home/pi/projects/imu-vibe-anomaly/data/*.pkl
8) Passive detection (no anomaly)
– Run detect mode without introducing anomalies. Expect mostly “normal” with stable scores. If many “ANOMALY” lines appear, expand baseline time or reduce contamination to 0.02.
9) Active anomaly test
– Introduce a mechanical disturbance:
– Add a small weight to a rotor.
– Touch the housing to change stiffness/coupling.
– Swap to a rougher surface.
– Observe “ANOMALY” lines; the score should be lower than normal windows.
10) Persistence and transition
– Remove the disturbance. The detector should return to “normal” within a couple of windows (1–2 seconds if win=2.0 s with 50% overlap).
Troubleshooting
- I2C device not found (i2cdetect shows “–” where 0x68/0x69 should be):
- Ensure I2C is enabled in /boot/firmware/config.txt:
- dtparam=i2c_arm=on
- dtparam=i2c_arm_baudrate=400000
- Check wiring: SDA to GPIO2 (pin 3), SCL to GPIO3 (pin 5), 3V3, and GND.
-
Some Pimoroni boards default to 0x69; try:
i2cdetect -y 1
python imu_vibe.py --addr 0x69 whoami -
Permission denied on /dev/i2c-1:
-
Add your user to the i2c group:
sudo adduser $USER i2c
newgrp i2c -
WHO_AM_I not 0xEA:
- Address incorrect (try 0x69).
- Board variant or wiring error. Double-check supply and connections.
-
If still failing, slow I2C to 100 kHz temporarily by setting:
dtparam=i2c_arm_baudrate=100000
in /boot/firmware/config.txt, then reboot. -
Unstable sampling interval or missed real-time deadlines:
- Reduce sample rate (e.g., –fs 150).
- Close other heavy processes (disable desktop/GUI if running).
-
Ensure proper power; undervoltage causes throttling. Check:
vcgencmd get_throttled
Value should be 0x0 for no throttling. -
Too many false positives:
- Increase baseline duration and variability (collect in multiple healthy operating conditions).
- Reduce contamination (e.g., 0.02).
- Increase window size (–win-s 3.0) for more stable spectral estimates.
-
Consider removing low-frequency drift by high-pass filtering acceleration (modify extract_features).
-
Fails to install SciPy or scikit-learn:
- Ensure libatlas-base-dev is installed (provided in apt install line).
- Increase swap if compiling from source (Bookworm usually provides wheels).
-
Alternatively, use simpler feature thresholds without ML as a stopgap.
-
Model not sensitive enough:
- Increase contamination (e.g., 0.1).
- Add higher-frequency bandpowers (extend 100–200 Hz if sampling supports it).
- Mount IMU more tightly to improve signal coupling.
Improvements
- Use SPI for higher throughput:
-
The ICM-20948 supports SPI. Switching from I2C to SPI with spidev can raise sampling rates and reduce latency.
-
Leverage sensor FIFO:
-
Configure and burst-read the FIFO to reduce per-sample I2C overhead and jitter, achieving more reliable high-rate capture.
-
Advanced signal processing:
- Envelope analysis for bearings (Hilbert transform).
- Cepstrum analysis and spectral kurtosis for resonance detection.
-
Order tracking for variable-speed rotating machinery.
-
Machine learning enhancements:
- Replace Isolation Forest with One-Class SVM or an autoencoder (e.g., TensorFlow Lite on the Pi).
-
Train separate models for different machine states (RPM ranges) and select dynamically.
-
Orientation compensation:
-
Remove gravity by estimating orientation (from gyro integration + complementary filter/accel) and projecting to the horizontal plane.
-
Edge deployment:
- Package the detector as a systemd service.
- Add MQTT publishing of anomaly events.
-
Persist logs with timestamps and scores to CSV or InfluxDB.
-
Calibration refinement:
- Temperature compensation for gyro drift.
-
Multi-position accelerometer calibration to refine offsets and scale.
-
Robustness:
- Use hardware timestamping via GPIO interrupts and DRDY pins for precise timing.
- Add watchdog/retry logic on I2C transfer errors.
Final Checklist
- Raspberry Pi OS Bookworm 64-bit is installed and up to date.
- I2C enabled via raspi-config or /boot/firmware/config.txt; baud set to 400 kHz.
- Wiring verified:
- 3V3 (pin 1) to IMU 3V3
- GND (pin 6) to IMU GND
- SDA1 (pin 3) to IMU SDA
- SCL1 (pin 5) to IMU SCL
- i2cdetect shows 0x68 (or 0x69).
- Python venv created at /home/pi/venvs/imu-venv and activated.
- Packages installed:
- numpy==1.26.4, scipy==1.11.4, scikit-learn==1.3.2, joblib==1.3.2, smbus2==0.4.3
- WHO_AM_I check returns 0xEA.
- Baseline collected (≥60 s) and stored in /home/pi/projects/imu-vibe-anomaly/data.
- Model trained; scaler.pkl and iforest.pkl present.
- Live detection runs, prints “normal” under healthy conditions.
- Anomaly is detected when introducing a controlled vibration change.
- Troubleshooting items reviewed if any step fails.
- Considered improvements for higher fidelity or deployment.
This completes a practical, end-to-end vibration anomaly detection pipeline on Raspberry Pi 4 Model B using a Pimoroni ICM-20948 9DoF IMU with Raspberry Pi OS Bookworm and Python 3.11.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



