To detect sudden changes, I tried an SR‑CNN(Spectral Residual and Convolutional Neural Network) model that looks at a small window of recent values. It assigns an anomaly score to each point, and if the newest score crosses a threshold, the system treats it as a spike.

The sample application uses ML.NET SR‑CNN to processes a rolling buffer (ConcurrentQueue) of recent samples and publishes to an Message Queue Telemetry Transport(MQTT) topic anomalies as they are detected.
private sealed class TopicRuntime
{
public object Lock { get; } = new();
public Queue<Model.TimeSeriesData> Buffer { get; }
public int MaxBufferSize { get; }
public ITransformer? Transformer { get; set; }
public TopicRuntime(Model.TopicConfiguration configuration)
{
MaxBufferSize = configuration.SrCnnSettings.WindowSize+ configuration.SrCnnSettings.LookaheadWindowSize+ configuration.SrCnnSettings.BackAddWindowSize;
Buffer = new Queue<Model.TimeSeriesData>(MaxBufferSize);
}
}
Every MQTT topic gets its own little SR‑CNN workspace: a lock to keep inference thread‑safe, a buffer that holds just enough recent samples for the model to work, and a transformer that runs the spike‑detection pipeline. The buffer size is calculated from the _applicationSettings windowSize, LookaheadWindowSize, and BackAddWindowSize settings so the model always sees the sequence length it has trained for.
var topicEstimator = _TopicEstimators.GetOrAdd(subscribedTopic, key => new TopicRuntime(_applicationSettings.SubscribedTopics[key]));
List<Model.SpikePrediction> spikePredictions;
lock (topicEstimator.Lock)
{
topicEstimator.Buffer.Enqueue(new Model.TimeSeriesData { Value = value });
while (topicEstimator.Buffer.Count > topicEstimator.MaxBufferSize) topicEstimator.Buffer.Dequeue();
if (topicEstimator.Buffer.Count < subscribedTopicSettings.SrCnnSettings.WindowSize)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Not enough data for prediction (have {topicEstimator.Buffer.Count}, need {subscribedTopicSettings.SrCnnSettings.WindowSize})");
return;
}
var data = _mlContext.Data.LoadFromEnumerable(topicEstimator.Buffer);
if (topicEstimator.Transformer is null)
{
var estimator = _mlContext.Transforms.DetectAnomalyBySrCnn(
outputColumnName: nameof(Model.SpikePrediction.Prediction),
inputColumnName: nameof(Model.TimeSeriesData.Value),
windowSize: subscribedTopicSettings.SrCnnSettings.WindowSize,
backAddWindowSize: subscribedTopicSettings.SrCnnSettings.BackAddWindowSize,
lookaheadWindowSize: subscribedTopicSettings.SrCnnSettings.LookaheadWindowSize,
averagingWindowSize: subscribedTopicSettings.SrCnnSettings.AveragingWindowSize,
judgementWindowSize: subscribedTopicSettings.SrCnnSettings.JudgementWindowSize,
threshold: subscribedTopicSettings.SrCnnSettings.Threshold);
topicEstimator.Transformer = estimator.Fit(data);
}
var transformed = topicEstimator.Transformer.Transform(data);
spikePredictions = [.. _mlContext.Data.CreateEnumerable<Model.SpikePrediction>(transformed, reuseRowObject: false)];
}
SR‑CNN needs a warmup period. Early on, it doesn’t have enough history, so its baseline is shaky and it tends to over‑react, flagging lots of spikes. After the model has seen enough samples (WindowSize+LookaheadWindowSize+BackAddWindowSize), it settles down, it understands the normal noise and recognises patterns. But, if it runs for a very long time, it can become almost too stable, making it slower to react to sudden changes.
To keep things flexible, the input transformer uses CS‑Script to turn the message payload (in this example JSON) into a C# object and then pulls out the one value (a float) the SR‑CNN model needs. In this case, it reads the Cm field of the Seeedstudio Ultrasonic Ranger and returns it as a floating‑point number. Each device type (in this example a Seeedstudio SKU 101991042) gets its own tiny script, making the system easy to extend.
//---------------------------------------------------------------------------------
// Copyright (c) May 2026, devMobile Software
//
/*
{
"ClientID:"Device123",
"Mm":269,
"Cm":26.8999996,
"Temperature":24.2999992
}
*/
using System; // Donot remove this as required for InvalidOperationException
using devMobile.IoT.MqttTransformers;
internal class SKU101991042
{
public string ClientID { get; set; } = string.Empty;
public int Mm { get; set; }
public float Cm { get; set; }
public float Temperature { get; set; }
}
public class InputSKU101991042 : IInputMessageTransformer
{
public float Transform(string topic, byte[] payload)
{
var json = System.Text.Encoding.UTF8.GetString(payload);
var obj = System.Text.Json.JsonSerializer.Deserialize<SKU101991042>(json) ?? throw new InvalidOperationException("Failed to deserialize payload");
return obj.Cm;
}
}
The output transformer takes the spike‑detection result and turns it into a simple JSON message that is published to the specified MQTT topic. It builds a data transfer object (DTO) containing the detection type, topic, value, raw score, and magnitude, then serialises it and returns the UTF‑8 bytes. Each CS-Script transformer script defines exactly how the spike result should look, making the system flexible and easy to extend.
//---------------------------------------------------------------------------------
// Copyright (c) May 2026, devMobile Software
//
using System.Text.Json;
using System.Text.Json.Serialization; // Do not remove this using directive as it is required for the JsonIgnoreCondition
using devMobile.IoT.MqttTransformers;
public class SpikeOutputTransformer : ISpikeOutputMessageTransformer
{
private static readonly JsonSerializerOptions _serializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
public byte[] Transform(string topic, double value, double rawScore, double magnitude)
{
var obj = new
{
DetectionType = "Spike",
Topic = topic,
Value = value,
RawScore = rawScore,
Magnitude = magnitude
};
string payload = JsonSerializer.Serialize(obj, _serializerOptions);
return System.Text.Encoding.UTF8.GetBytes(payload);
}
}
The MQTTX application from EMQX provides a clear view of the messages published by the spike‑detection pipeline. Each alert arrives as a JSON payload containing the detection type, topic name, measured value, raw SR‑CNN score, and spike magnitude. This makes it easy to monitor devices in close to “real time” and verify that spike events are being reliably detected and alerted correctly.
In my testing, SR‑CNN consistently produced more reliable results with my device data than IID or SSA‑based anomaly‑detection approaches. The residual CNN architecture handled the noise characteristics and temporal patterns far better, giving fewer false positives and more stable detections especially across long‑running sessions.
This blog post assumes you have read my earlier spike and change point detection posts.

























