The core idea: Welford's online algorithm
The standard way to compute mean and standard deviation requires storing all samples first, then doing two passes. That's fine in Python. On a microcontroller with 320 KB of RAM, it's a non-starter.
John Welford solved this in 1962. His algorithm maintains a running mean and variance in a single pass, using only three values:
typedef struct {
uint32_t count; // samples processed
float mean; // running mean
float M2; // sum of squared deviations from mean
float threshold_sigma; // alert threshold (e.g. 3.0 = 3σ)
uint32_t min_samples; // warm-up period before detection starts
uint32_t window_size; // 0 = infinite, N = sliding window
} ArdentZScore; // sizeof == 24 bytes
The update step is four lines:
bool ard_zscore_update(ArdentZScore* ctx, float sample) {
ctx->count++;
float delta = sample - ctx->mean;
ctx->mean += delta / ctx->count;
float delta2 = sample - ctx->mean;
ctx->M2 += delta * delta2;
if (ctx->count < ctx->min_samples) return false;
float variance = ctx->M2 / (ctx->count - 1);
float stddev = sqrtf(variance);
if (stddev < 1e-9f) return false;
float zscore = fabsf(sample - ctx->mean) / stddev;
return zscore > ctx->threshold_sigma;
}
That's it. No history buffer, no second pass, numerically stable over millions of samples.
Why not the naive formula? The naive formula computes variance = (sum_of_squares / n) - mean². That accumulates floating point error over time. On a MCU running for months, the variance goes negative. Welford avoids this entirely.
Why Z-Score isn't enough
Z-Score detects sudden spikes: a value that's 4σ away from the running mean. But it misses slow drift — because the running mean follows the drift.
Imagine a bearing that degrades over three days. At any given moment, the current value is "normal" relative to recent history. Z-Score sees nothing. But a second detector that compares a fast-moving average to a slow reference catches it immediately.
That's EWMA Drift — two exponential moving averages in parallel:
typedef struct {
float ewma_fast; // α = 0.1 — follows signal closely
float ewma_slow; // α = 0.01 — stable reference
float alpha_fast;
float alpha_slow;
float threshold;
uint32_t count;
} ArdentDrift; // sizeof == 24 bytes
bool ard_drift_update(ArdentDrift* ctx, float sample) {
ctx->count++;
ctx->ewma_fast = ctx->alpha_fast * sample + (1.0f - ctx->alpha_fast) * ctx->ewma_fast;
ctx->ewma_slow = ctx->alpha_slow * sample + (1.0f - ctx->alpha_slow) * ctx->ewma_slow;
if (ctx->count < 10) return false;
return fabsf(ctx->ewma_fast - ctx->ewma_slow) > ctx->threshold;
}
Run both in parallel on the same data stream:
bool spike = ard_zscore_update(&spike_detector, sample);
bool drift = ard_drift_update(&drift_detector, sample);
if (spike || drift) trigger_alert();
| Failure mode | Z-Score | EWMA Drift |
|---|---|---|
| Voltage spike (+5σ, 1 sample) | ✅ Detected | ❌ Missed |
| Bearing wear (slow +0.05/sample) | ❌ Missed | ✅ Detected |
Together, they cover the two fundamental failure signatures in industrial signals.
Integrating it in your firmware
Three steps.
Step 1 — Declare at file scope (no heap)
#include "ardent/zscore.h"
#include "ardent/drift.h"
static ArdentZScore spike;
static ArdentDrift drift;
Step 2 — Init once in setup()
ard_zscore_init(&spike, 3.0f, 30); // 3σ threshold, 30-sample warm-up
ard_drift_init(&drift, 0.1f, 0.01f, 0.5f); // fast α, slow α, threshold
Step 3 — Call in loop()
float sample = (float)analogRead(SENSOR_PIN);
bool anomaly = ard_zscore_update(&spike, sample)
|| ard_drift_update(&drift, sample);
if (anomaly) {
digitalWrite(LED_PIN, HIGH);
// or publish to MQTT, trigger relay, log to SD card...
}
That's the entire integration. The detectors are zero-allocation, thread-safe (no globals except the structs you declare), and have no side effects.
Validating on PC before touching hardware
One of the hard constraints I set from day one: every algorithm must compile and run on a desktop with GCC. No hardware required to run the test suite.
cd edge-core/tests
make
Running test suite: zscore
[PASS] init: mean=0, stddev=0 before warmup
[PASS] warmup: no alerts during first 30 samples
[PASS] spike: +5σ detected on sample 100
[PASS] spike: -5σ detected on sample 101
[PASS] no false positive: 1000 normal samples, 0 alerts
...
Running test suite: drift
[PASS] ramp +0.05/sample detected at sample 67
...
391/391 tests passed — 0 failing
391 tests across 13 suites. If you change something, you know immediately whether it still works — before you pick up a USB cable.
Running on real hardware
Hardware: ESP32-CAM + MPU-6050 accelerometer. Wiring:
MPU-6050 → ESP32-CAM
VCC → 3.3V
GND → GND
SDA → GPIO13
SCL → GPIO14
AD0 → GND (I2C address 0x68)
One gotcha worth saving you an hour: if you're using a CH340 USB-UART adapter, never use board=esp32cam in platformio.ini. The PSRAM initialization it triggers crashes silently before setup() runs — nothing appears in the serial monitor. Use board=esp32dev instead. The WiFi, I2C, and GPIO work identically.
Flash:
cd edge-core/examples/esp32/imu_zscore
cp src/config.h.example src/config.h # fill WiFi + MQTT credentials
pio run --target upload
pio device monitor --baud 115200
Serial output:
[ARDENT] Pulse initialized
[ARDENT] WiFi connected — 192.168.1.42
[ARDENT] MQTT connected
[ARDENT] accel: 9.82 (NORMAL, z=0.31)
[ARDENT] accel: 9.79 (NORMAL, z=0.18)
[ARDENT] ANOMALY DETECTED — value=23.47 z=4.82
[ARDENT] ANOMALY DETECTED — value=21.13 z=4.11
[ARDENT] accel: 10.02 (NORMAL, z=0.44)
Shake the sensor, the anomaly appears. Put it down, it goes back to NORMAL.
The third detector: MAD
For completeness, the SDK also includes a MAD (Median Absolute Deviation) detector. MAD uses the median instead of the mean, which makes it robust to outliers by construction. The median doesn't get pulled by extreme values the way the mean does.
It's more expensive (sliding window with insertion sort, ~200 bytes), but it's the right tool for signals that aren't Gaussian — vibration, ECG, pressure sensors in turbulent flow.
#include "ardent/mad.h"
static ArdentMAD vibration;
ard_mad_init(&vibration, 32, 2.5f); // window=32, threshold=2.5 MAD units
bool anomaly = ard_mad_update(&vibration, sample);
What this is
The SDK (called Ardent Pulse) is open source under LGPL v3 on GitHub: github.com/Antoine005/ardent
If you want the full package with the test suite, ESP32 examples (smoke test, MPU-6050 integration, camera-based fire detection, person detection with TFLite), and the Quick Start Guide PDF, it's available on Gumroad for $39: ardentai.gumroad.com/l/ugmrvk?utm_source=devto
The repository also includes Ardent Forge (Python AutoML pipeline to calibrate detector parameters on your real sensor data) and Ardent Watch (Next.js dashboard with MQTT ingestion, live anomaly visualization, fleet supervision). The Full Stack bundle is $149.
The constraint list
If you're thinking about adapting this for your own MCU or use case, these are the constraints I designed around:
-
Zero malloc — every struct is stack or static.
valgrindconfirms no heap allocations. - < 4 KB RAM per detector — Z-Score and EWMA Drift are 24 bytes each.
- < 1 ms per sample at 80 MHz — measured at ~0.04 µs. 25× margin.
-
C99 pure — no C++, no POSIX, no OS. Compiles with
-std=c99 -pedantic -Wall -Wextra -Werror. -
HAL mandatory — algorithms never call registers directly. Every MCU port implements the same
hal_*.hinterfaces. - Testable on PC — if you can't test it without hardware, you can't iterate.
These aren't arbitrary. On a field-deployed industrial sensor or a defense system that can't be recalled, a single malloc failure or a portability assumption can be a production incident.
Questions? Drop them below or open an issue on GitHub. Happy to explain the drift detection tuning in more detail — it's surprisingly sensitive to the α values.























