Skip to main content

statorscope

Motor Current Signature Analysis that knows when to refuse.

CI Python 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.


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.2.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.2.0
File Size Uploaded
statorscope-0.2.0.tar.gz 142.8 kB Details

Built distribution (wheel)

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

Total release size: 185.6 kB

Release files / statorscope-0.2.0.tar.gz

Download URL statorscope-0.2.0.tar.gz
Size 142.8 kB
Tags Source
SHA-256 checksum
How to use checksums
e21c7a0f104d018b959cebb73fcb01f11403229bede68c27c44554aa2474b932
BLAKE2b-256 checksum
How to use checksums
c9e985a8fd24cb41eda92e46261df50f146bf0e0b34b82a51557500bfbfd30ec
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.2.0-py3-none-any.whl

Download URL statorscope-0.2.0-py3-none-any.whl
Size 42.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ec2475d43f73b917da33107a93cfd713ec3d30ce491ee6051f79222d6dac6a81
BLAKE2b-256 checksum
How to use checksums
d3e22f2cb7b5c1547b21dc728c3b81a232ed5c3830b60a7e0654eb7cbad99988
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

0.3.0

2 release files

This release

0.2.0 This release

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