Skip to main content

statorscope

Motor Current Signature Analysis that knows when to refuse.

PyPI Python CI License

Detect broken rotor bars, stator inter-turn faults and air-gap eccentricity from three-phase motor current — and get told, up front, when your acquisition cannot support the answer.

from statorscope import Motor, Recording, diagnose

motor = Motor(pole_pairs=2, rotor_bars=28, line_hz=50.0)  # pole PAIRS, not poles
rec = Recording.from_text("capture.txt", time_column=0, time_unit="ms")

print(diagnose(rec, motor))

Why this exists

Every MCSA tool will happily tell you a motor has a broken rotor bar. Most of them will tell you that about a healthy motor too, and never mention it.

Broken-bar sidebands sit at (1 ± 2ks)·f — within a couple of Hz of a fundamental that is 40–60 dB larger. Two things routinely counterfeit them:

Counterfeit What it does What most tools do
Sample-clock jitter Smears the fundamental into a phase-noise skirt, right where the sidebands live Threshold on amplitude → fires every time
Window leakage A Hann window has −31 dB sidelobes; the fault you want is at −45 dBc Use the default window → fires every time

This library was built after auditing a real 2018 MCSA rig whose detector lit every fault lamp on every run. Two independent causes: an unsatisfiable comparison in the code, and an acquisition chain that manufactured the evidence. The second one is the interesting one, because no amount of signal processing fixes it — and nothing in the tooling told the operator.

statorscope makes that check a first-class, non-optional part of the pipeline.

See it for yourself

examples/false_positive.py runs one healthy motor through two acquisition chains and compares a naive amplitude detector with statorscope:

A HEALTHY motor. No broken bars. Two acquisition chains.

--- hardware clock ---
  naive amplitude detector : clear (-86.0 dBc)
  statorscope              : clear
  clock verdict            : GOOD
  measured floor           : -86.9 dBc

--- millis() logging ---
  naive amplitude detector : BROKEN BAR (-12.8 dBc)     <-- false positive
  statorscope              : clear
  clock verdict            : UNRELIABLE
  measured floor           : -16.5 dBc

Same healthy motor. Change nothing but the clock, and the naive detector diagnoses a broken rotor bar 73 dB louder than the truth. statorscope demo --healthy --jitter shows the full report, which says why:

Clock audit: UNRELIABLE
  samples             : 32,240
  sample rate         : 602.83 Hz
  instantaneous range : 250-1000 Hz
  fast jitter (rms)   : 3513.4 us (211.8% of sample interval)
  slow drift (rms)    : 16.7 ms
  timestamp resolution: 1000 us
  MEASURED floor      : -16.5 dBc (offset 0.5-6.0 Hz)
  incipient fault at  : -50.0 dBc
  headroom            : -33.5 dB
  ! The measured floor (-16.5 dBc) sits ABOVE the level a real fault would
    produce (-50.0 dBc). Sidebands found here cannot be distinguished from
    acquisition noise. Fix the acquisition, not the maths.

VERDICT: UNSUPPORTED. The clock audit failed, so the findings above cannot be
distinguished from acquisition noise. Do not act on them.

Install

uv add statorscope             # or: pip install statorscope
uv add "statorscope[cli]"      # + the rich CLI
uv add "statorscope[all]"      # + plotting

Python 3.11+. Runtime dependencies are numpy and scipy, nothing else.


What it does

1. Audits the acquisition before it analyses anything

It measures the noise floor in the band where sidebands live and compares it to the level a real fault would produce:

from statorscope import assess_clock

q = assess_clock(rec, line_hz=50.0)
print(q.verdict)  # TrustLevel.UNRELIABLE
print(q.measured_floor_dbc)  # -16.5
print(q.headroom_db)  # -33.5
print(q.explain())

Measured, not predicted — and that distinction was earned the hard way. The first version of this gate predicted the floor from the standard aperture-jitter relation. On real 2018 hardware it was wrong by 30 dB: the model assumes white jitter, but a software-timed loop drifts, and correlated drift concentrates near the carrier instead of raising the broadband floor. The model is still shipped (jitter_noise_floor_dbc) for designing an acquisition chain up front, but the verdict comes from the data.

Timing is still characterised when timestamps are present, and split into the two components that need different fixes:

  • fast jitter — lands in the sideband band, raises the floor → needs a better clock
  • slow drift — shifts the frequency axis → needs grid_lock, not new hardware

It also flags when apparent jitter is really timestamp quantisation (a 1 ms-resolution millis() log makes clean sampling look jittery). Most tools conflate all three; this one tells you which you have.

2. Calibrates your frequency axis against the grid — free

If your sample rate came from millis(), a USB packet counter, or anything other than a real crystal, your frequency axis has an unknown scale error. Every fault frequency you search is in the wrong place.

The grid is a calibration tone. Utility frequency is regulated to ~±0.05 Hz, so any larger deviation in your measured fundamental is your own time base:

from statorscope import grid_lock

lock = grid_lock(rec, nominal_hz=50.0)
print(lock.explain())
rec = lock.recording  # corrected fs, use this downstream
Grid-lock calibration
  measured fundamental : 50.3979 Hz (nominal 50.0 Hz)
  sample rate          : 620.000 -> 615.105 Hz
  time-base error      : +7,958 ppm (0.796% fast)
  ! Uncalibrated, every fault frequency would have been offset by 0.796%.

3. Measures how wide the carrier actually is

suppress_fundamental fits and removes a single sinusoid. A carrier smeared by time-base drift is not one — so a large residue survives right beside it, and that residue is locally prominent, which makes it look exactly like a low-slip broken-bar sideband.

statorscope measures the carrier's occupied bandwidth and refuses to look inside it:

q = assess_clock(rec)
q.carrier_halfwidth_hz  # 0.05 Hz on a clean capture, 3.9 Hz on a drifting one

A carrier smeared across ±3.9 Hz blinds every slip below 3.9%. Induction motors run at 1–5% slip, so such a recording cannot see a normally loaded machine at all — and the audit says so rather than reporting the residue as a fault.

This was found by running the library against the real 2018 recordings that motivated it: it reported all three fault types on every file. The synthetic suite had missed it because a clean synthetic carrier suppresses perfectly, leaving no residue to trip over. TestSmearedCarrier covers it now.

4. Rejects skirts, keeps sidebands

Detection scores prominence over a local median floor, not absolute amplitude. A discrete sideband stands above its neighbourhood. A phase-noise skirt has level everywhere and prominence nowhere — so it scores low and is discarded.

The threshold is measured, not chosen by taste: white noise alone reaches ~9 dB prominence over a few-bin search window, so anything below that flags healthy machines. tests/test_detect.py::TestThresholdCalibration pins the measurement so a future change has to re-justify itself.

The fundamental is removed by least-squares fit and subtraction in the time domain, which eliminates its leakage entirely rather than attenuating it. The default window is Blackman-Harris (−92 dB sidelobes).

5. Estimates slip without a tachometer

Slip is the crux of MCSA: the sidebands move with it, and a typed-in nameplate RPM is usually wrong. statorscope searches the sideband geometry directly, then refines from the interpolated peak separation:

from statorscope import estimate_slip

est = estimate_slip(spectrum, motor)
print(est.slip, est.rpm, est.confident)  # 0.0301  1454 rpm  True

6. Ships the fault models, correctly

Mechanism Signature
Broken rotor bar (1 ± 2ks)·f
Stator inter-turn f·[n(1−s)/p ± k]
Air-gap eccentricity f ± k·f_r
Rotor slot harmonics f·[(R ± n_d)(1−s)/p ± n_ws]
Bearing races abs(f ± m·BPFO/BPFI)

Motor takes pole_pairs, spelled out, because poles-vs-pole-pairs is the single most common unit error in MCSA code.


CLI

statorscope audit capture.txt --line-hz 50          # can I trust this data?
statorscope analyse capture.txt -p 2 -b 28          # full diagnosis
statorscope demo --fault -42                        # synthetic, known answer
statorscope demo --healthy --jitter                 # the false positive, live

audit and analyse exit non-zero when the data cannot support a claim, so they drop straight into CI or a monitoring cron.


Benchmark: labelled public data

Validated against BBIM2023 (Mendeley Data, CC BY 4.0) — two induction motors, 0–3 broken rotor bars at full load, labelled, produced independently of this library.

case label true slip est. slip error detected level severity
M24Ns_29Nb/H healthy 0.0393 0.0778 — no — —
M24Ns_29Nb/1B 1 broken bar 0.0405 0.0405 0.0000 yes −38.9 dBc moderate
M24Ns_29Nb/2B 2 broken bars 0.0421 0.0421 0.0000 yes −32.3 dBc severe
M24Ns_29Nb/3B 3 broken bars 0.0439 0.0443 0.0004 yes −28.5 dBc severe
M36Ns_44Nb/H healthy n/a 0.0603 — no — —
M36Ns_44Nb/1B 1 broken bar n/a 0.0490 — yes −44.8 dBc moderate
M36Ns_44Nb/2B 2 broken bars n/a 0.0503 — yes −36.7 dBc moderate
M36Ns_44Nb/3B 3 broken bars n/a 0.0531 — yes −31.1 dBc severe

8/8 correct. No false positives, no false negatives. Sensorless slip error against the simulator's ground-truth rotor speed: mean 0.0002, worst 0.0004 — with no tachometer and no nameplate speed.

Severity rises monotonically with broken-bar count in both motors independently (−38.9 → −32.3 → −28.5 and −44.8 → −36.7 → −31.1), which is the physics, not a fit.

Reproduce it:

# download both workbooks from the Mendeley link above
uv run --with openpyxl python benchmarks/bbim2023.py path/to/*.xlsx

Honest caveat: BBIM2023 is simulated, from a validated motor model. It exercises the analysis, not the sensor chain — so it proves the maths, while the clock audit above is what covers the acquisition. Benchmarking against experimental labelled data (IEEE DataPort's broken-bar database) is still open; it needs an account and a 6.7 GB download.

This benchmark earned its keep immediately: it exposed two real bugs that the synthetic suite could not, because both only appear on near-noiseless data. See TestAbsoluteFaultFloor and TestNoiselessCarrierWidth.

Testing against known truth

The synthesiser generates three-phase current with faults injected at specified levels, plus a jitter model, so the test suite asserts in both directions:

from statorscope import Motor, synthesize, diagnose

motor = Motor(pole_pairs=2, rotor_bars=28)
rec, truth = synthesize(motor, slip=0.03, broken_bar_dbc=-42)

report = diagnose(rec, motor)
assert report.faults[0].detected
assert abs(report.slip.slip - 0.03) < 0.002

The headline regression test is the one that matters: a healthy machine with a bad clock must never come back supported.


Roadmap

  • Loaders and benchmarks for the public labelled datasets (IEEE DataPort broken-rotor-bar database, Mendeley current-signature set)
  • Startup/transient analysis — track the fault component through the run-up, which sidesteps slip estimation entirely
  • Park's Vector / EPVA, using all three phases (needs simultaneous sampling)
  • Reference firmware for a Pico/ESP32 + ADS131M04 front end (24-bit, simultaneous-sampling, ~$15)
  • MUSIC/ESPRIT high-resolution estimators for short records

Scope and honesty

  • These are screening tools. A dBc level is not a work order.
  • Severity bands are field heuristics, not standards; they are constants you can change, and they are documented as such in detect.SEVERITY_BANDS.
  • The jitter floor assumes white jitter. Structured jitter concentrates into discrete spurs and can be worse at specific offsets. It is a floor, not a promise.
  • Current-based bearing diagnosis is weak compared to vibration. It is included because it is cheap, not because it is equivalent.

Contributing

git clone https://github.com/ali-kin4/statorscope
cd statorscope
uv sync --all-extras
uv run pytest
uv run ruff check .
uv run mypy src

Issues and PRs welcome — particularly validation against real labelled data, and fault models for machine types not covered here.

License

Apache-2.0.

Metadata

Release files for statorscope 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for statorscope 0.3.0
File Size Uploaded
statorscope-0.3.0.tar.gz 148.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for statorscope 0.3.0
File Interpreter ABI Platform
statorscope-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 194.9 kB

Release files / statorscope-0.3.0.tar.gz

Download URL statorscope-0.3.0.tar.gz
Size 148.1 kB
Tags Source
SHA-256 checksum
How to use checksums
89c0132aa35ba77801e6b9991f93350a531165751231477f47235b72bb698949
BLAKE2b-256 checksum
How to use checksums
42d1d5aa654d881ea1832d3a0fc9c268d70a3055259ad7c835bba67354d26864
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / statorscope-0.3.0-py3-none-any.whl

Download URL statorscope-0.3.0-py3-none-any.whl
Size 46.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
317c89f79c2be83956eeaca8bc5c7f24d9aad91c063eb815dd5b48c42032a676
BLAKE2b-256 checksum
How to use checksums
8026833f83b4bfc4416baa1b79ce75c51253d4edf4094676743bc0c8c4cf61b1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page