Skip to content

Argus

Industrial anomaly detection for Modbus register data. Statistical analysis catches drift and faults in real time. A local language model turns raw numbers into plain-English diagnoses.

Python FastAPI React SQLite Ollama Modbus TCP 3-Sigma Detection CUSUM
// the problem

Equipment doesn't fail all at once

On a shop floor or in a data center, equipment rarely fails without warning. There are almost always precursor signals buried in the live data: a temperature reading that climbs a few degrees too slowly to trigger a hard alarm, a pressure value that drifts outside its normal band for a few minutes before recovering. By the time a threshold alarm fires, the problem is often already expensive.

The standard approach relies on static thresholds configured in a PLC or SCADA system. You set a high-limit, you wait for it to trip. That works for hard faults but misses the soft ones entirely. What I wanted was a system that could watch register values over time, learn what normal looks like for each register, and flag deviations before they crossed any fixed limit.

I also wanted the output to be useful to someone who isn't a data scientist. A z-score of 3.4 doesn't tell a technician much. A sentence like "bearing friction or insufficient lubrication detected, check lube system" does.


// how it works

Statistical detection, local language model, live dashboard

Argus connects to a Modbus TCP device (or the built-in simulator) and polls a configurable set of holding registers on a fixed interval. Each register maintains a rolling history window. On every tick, the detector computes the mean and standard deviation of the recent window, then scores the latest value as a z-score. A score above 2.0 triggers a drift warning. A score above 3.0 triggers a fault alert.

There are two additional detection methods running alongside the sigma test. CUSUM (cumulative sum control) catches slow directional drift that doesn't produce a high z-score on any single tick but reveals a persistent upward or downward trend over time. The rate-of-change check catches abrupt spikes: if a value jumps by more than a configured multiple of its standard deviation in a single interval, that's a separate fault class from a gradual drift.

When a fault is detected, Argus calls a locally-running language model via Ollama. The model gets the register name, the current value, the historical mean, and the deviation, and it returns a diagnosis in plain English. Nothing leaves the local network. The same system that runs on a production floor can run completely offline.

The frontend is a React dashboard with live WebSocket updates. Each register card shows the current value, a sparkline of the last 30 readings, a status badge (normal / drift / fault), and a range marker. An event log on the right side streams every detection event and diagnosis as they happen.

All events are persisted to SQLite. The historical record lets you go back and look at how a register behaved in the hours before a fault, which is useful for root-cause analysis after the fact.


// the technical details

Bootstrap windows, sensitivity tuning, and a React performance lesson

The detection logic lives in a single Python module. I spent a fair amount of time deciding how to handle the bootstrap period -- the first few seconds after startup when the history window is too short to produce a meaningful standard deviation. The solution was simple: require a minimum window length before enabling detection. If the window has fewer than 8 values, no alerts fire. It's a blunt rule, but it prevents false positives during startup.

The bigger design question was how to tune sensitivity without making it a parameter that requires expert knowledge to set. The 3-sigma threshold is standard and well-understood, so it stayed. CUSUM requires two parameters (a reference value and a decision threshold), and I exposed both in the configuration file alongside sane defaults. Most users can leave them alone.

One thing I learned building the frontend: rendering a sparkline for each register every polling interval is fast enough when you're drawing raw SVG polylines and skipping the React reconciler for the SVG internals. Trying to do it as controlled React state caused noticeable jank at fast poll rates. The fix was to hold the SVG elements as refs and mutate them directly, which felt wrong in a React component but performed well.

The test suite covers the detector in isolation: normal values, hard limit violations, CUSUM accumulation, rate-of-change trips, and recovery after a fault clears. The detector is a pure function of its input config and register history, which makes it straightforward to test without mocking any I/O.