Detect anomalies in a single sensor tag using ML.NET SSA (Singular Spectrum Analysis). Supports two variants: Spike Detection for sudden outliers and ChangePoint Detection for gradual drift. The AI generates the full C# Script Class, connects it to a live tag, creates output tags, and configures model persistence.
| Table of Contents |
|---|
When to Use This Skill
Category | Items |
|---|---|
Tools |
|
Tables |
|
Create tags to receive the model's predictions. Place them under a /ML/ subfolder relative to the input tag's asset path.
| Code Block |
|---|
get_table_schema('UnsTags')
|
| Code Block |
|---|
{
"table_type": "UnsTags",
"data": [
{ "Name": "<AssetPath>/ML/AnomalyScore", "DataType": "Double", "Description": "Anomaly score (0=normal, higher=anomalous)" },
{ "Name": "<AssetPath>/ML/IsAnomaly", "DataType": "Boolean", "Description": "True when anomaly detected" },
{ "Name": "<AssetPath>/ML/LastPrediction", "DataType": "DateTime", "Description": "Timestamp of last prediction" }
]
}
|
Replace <AssetPath> with the actual asset folder path (e.g., Plant/Reactor1).
Script Classes using ML.NET require the NamespaceDeclarations field. ML.NET is pre-installed with FrameworX — no NuGet packages needed.
| Warning |
|---|
Critical: The field |
| Code Block |
|---|
"NamespaceDeclarations": "Microsoft.ML;Microsoft.ML.Data;Microsoft.ML.Transforms;Microsoft.ML.Transforms.TimeSeries;Microsoft.ML.Transforms.Text;Microsoft.ML.Trainers;Microsoft.ML.TimeSeries"
|
Always use the @Tag. prefix to read or write tag values:
| Code Block | ||
|---|---|---|
| ||
// Read from a tag
double temp = @Tag.Plant/Reactor1/Temperature.Value;
// Write to a tag
@Tag.Plant/Reactor1/ML/AnomalyScore.Value = score;
@Tag.Plant/Reactor1/ML/IsAnomaly.Value = true;
@Tag.Plant/Reactor1/ML/LastPrediction.Value = DateTime.Now;
|
Important: ML.NET expects float but FrameworX tags use double. Always cast with (float) when feeding ML.NET and cast back to double when writing to tags.
Use for sudden outliers, spikes, abnormal readings (e.g., pressure spikes, vibration bursts, temperature jumps).
| Code Block | ||
|---|---|---|
| ||
var pipeline = mlContext.Transforms.DetectSpikeBySsa(
outputColumnName: "Prediction",
inputColumnName: nameof(SensorData.Value),
confidence: 95.0,
pvalueHistoryLength: 10,
trainingWindowSize: 100,
seasonalityWindowSize: 10);
|
Output: double[] Prediction with [0]=isAnomaly, [1]=score, [2]=pValue
Use for gradual drift, regime shifts, process changes (e.g., slow pressure decay, baseline change).
| Code Block | ||
|---|---|---|
| ||
var pipeline = mlContext.Transforms.DetectChangePointBySsa(
outputColumnName: "Prediction",
inputColumnName: nameof(SensorData.Value),
confidence: 95.0,
changeHistoryLength: 10,
trainingWindowSize: 100,
seasonalityWindowSize: 10);
|
Output: double[] Prediction with [0]=alert, [1]=score, [2]=pValue, [3]=martingaleValue
For ChangePoint, replace DetectSpikeBySsa with DetectChangePointBySsa and update the VectorType from [VectorType(3)] to [VectorType(4)].
| Code Block | ||
|---|---|---|
| ||
public class SensorData
{
public float Value { get; set; }
}
public class SpikePrediction
{
[VectorType(3)]
public double[] Prediction { get; set; }
}
private static MLContext mlContext = new MLContext(seed: 0);
private static ITransformer model;
private static IDataView lastTrainingDataView;
private static bool modelTrained = false;
private static List<SensorData> trainingBuffer = new List<SensorData>();
private const int MinTrainingSize = 100;
private static readonly string ModelPath = Path.Combine(@Info.GetExecutionPath(), "<ClassName>.mlnet");
public void Predict(double inputValue)
{
trainingBuffer.Add(new SensorData { Value = (float)inputValue });
if (!modelTrained && trainingBuffer.Count >= MinTrainingSize)
TrainModel();
if (modelTrained)
RunPrediction();
}
public void LoadModel()
{
if (File.Exists(ModelPath))
{
model = mlContext.Model.Load(ModelPath, out _);
modelTrained = true;
}
}
private void TrainModel()
{
lastTrainingDataView = mlContext.Data.LoadFromEnumerable(trainingBuffer);
var pipeline = mlContext.Transforms.DetectSpikeBySsa(
outputColumnName: "Prediction",
inputColumnName: nameof(SensorData.Value),
confidence: 95.0,
pvalueHistoryLength: 10,
trainingWindowSize: 100,
seasonalityWindowSize: 10);
model = pipeline.Fit(lastTrainingDataView);
modelTrained = true;
SaveModel();
}
private void SaveModel()
{
mlContext.Model.Save(model, lastTrainingDataView.Schema, ModelPath);
}
private void RunPrediction()
{
var dataView = mlContext.Data.LoadFromEnumerable(trainingBuffer);
var transformed = model.Transform(dataView);
var predictions = mlContext.Data.CreateEnumerable<SpikePrediction>(transformed, reuseRowObject: false).ToList();
var latest = predictions.Last();
@Tag.<AssetPath>/ML/IsAnomaly.Value = latest.Prediction[0] == 1;
@Tag.<AssetPath>/ML/AnomalyScore.Value = latest.Prediction[1];
@Tag.<AssetPath>/ML/LastPrediction.Value = DateTime.Now;
}
|
| Code Block |
|---|
get_table_schema('ScriptsClasses')
|
| Code Block |
|---|
{
"table_type": "ScriptsClasses",
"data": [
{
"Name": "<ClassName>",
"Code": "CSharp",
"Domain": "Server",
"ClassContent": "Methods",
"NamespaceDeclarations": "Microsoft.ML;Microsoft.ML.Data;Microsoft.ML.Transforms;Microsoft.ML.Transforms.TimeSeries;Microsoft.ML.Transforms.Text;Microsoft.ML.Trainers;Microsoft.ML.TimeSeries",
"Contents": "<AI-generated C# code from full class example above>"
}
]
}
|
| Warning |
|---|
Field names matter: the language field is |
| Code Block |
|---|
get_table_schema('ScriptsExpressions')
|
| Code Block |
|---|
{
"table_type": "ScriptsExpressions",
"data": [
{
"Name": "ML_Predict_<SensorName>",
"ObjectName": "",
"Expression": "@Script.Class.<ClassName>.Predict(@Tag.<AssetPath>.<Member>)",
"Execution": "OnChange",
"Trigger": "<AssetPath>"
}
]
}
|
| Warning |
|---|
Use |
Read the existing ServerStartup task first (document object — read-modify-write), then append:
| Code Block | ||
|---|---|---|
| ||
Script.Class.<ClassName>.LoadModel();
|
System.Math / CpuMath error. Instruct the user: “Before starting the runtime, please confirm your solution is set to Multiplatform: Solution → Settings → Target Platform = Multiplatform, then Product → Modify.”MinTrainingSize data points (default 100) before predictions beginLastPrediction timestamp is updatingMistake | Why It Happens | How to Avoid |
|---|---|---|
Missing ML.NET namespaces | Used | Always set |
| Called | Always use |
Non-empty | Expression tries to assign void return to a tag | Leave |
Used | Field does not exist — silently ignored | Use |
| Digital tags are | Use ternary: |
| Class compiles after Task that references it | Set |
| Solution targets .NET 4.8 | Switch to Multiplatform (.NET 8+) |
Wrong data types | ML.NET expects | Cast with |
Model lost on restart | SaveModel or LoadModel not wired | Always include both + wire LoadModel in ServerStartup |
| Children Display |
|---|