diff --git a/ARIEC61850.sln b/ARIEC61850.sln
index b6f282a..a386447 100644
--- a/ARIEC61850.sln
+++ b/ARIEC61850.sln
@@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.Cli", "apps\AR.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.SvPublisher", "apps\AR.Iec61850.SvPublisher\AR.Iec61850.SvPublisher.csproj", "{49560545-7AA9-44AC-8196-9D6D98241B04}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.VirtualRelayLab", "apps\AR.Iec61850.VirtualRelayLab\AR.Iec61850.VirtualRelayLab.csproj", "{4F02C69F-8B2D-4F9E-9A2B-1F5E7A3C6D41}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.IedDiscovery", "apps\AR.Iec61850.IedDiscovery\AR.Iec61850.IedDiscovery.csproj", "{A187DF4A-8A80-4DE0-B286-27A8E8BCAB9A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.IedSimulator", "apps\AR.Iec61850.IedSimulator\AR.Iec61850.IedSimulator.csproj", "{C3D12F10-2A81-43A6-9BBA-FB789E6641E2}"
@@ -20,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.Transports.Npca
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.Tests", "tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj", "{0D9D1E7D-FF86-49A3-A81D-38D3E0880BF8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AR.Iec61850.VirtualRelayLab.Tests", "tests\AR.Iec61850.VirtualRelayLab.Tests\AR.Iec61850.VirtualRelayLab.Tests.csproj", "{B7E2A1D4-73C6-4F90-8D8F-9A5B2C4E1F33}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -34,6 +38,10 @@ Global
{49560545-7AA9-44AC-8196-9D6D98241B04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49560545-7AA9-44AC-8196-9D6D98241B04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49560545-7AA9-44AC-8196-9D6D98241B04}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4F02C69F-8B2D-4F9E-9A2B-1F5E7A3C6D41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4F02C69F-8B2D-4F9E-9A2B-1F5E7A3C6D41}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4F02C69F-8B2D-4F9E-9A2B-1F5E7A3C6D41}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4F02C69F-8B2D-4F9E-9A2B-1F5E7A3C6D41}.Release|Any CPU.Build.0 = Release|Any CPU
{A187DF4A-8A80-4DE0-B286-27A8E8BCAB9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A187DF4A-8A80-4DE0-B286-27A8E8BCAB9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A187DF4A-8A80-4DE0-B286-27A8E8BCAB9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -58,6 +66,10 @@ Global
{0D9D1E7D-FF86-49A3-A81D-38D3E0880BF8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D9D1E7D-FF86-49A3-A81D-38D3E0880BF8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D9D1E7D-FF86-49A3-A81D-38D3E0880BF8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7E2A1D4-73C6-4F90-8D8F-9A5B2C4E1F33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7E2A1D4-73C6-4F90-8D8F-9A5B2C4E1F33}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7E2A1D4-73C6-4F90-8D8F-9A5B2C4E1F33}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7E2A1D4-73C6-4F90-8D8F-9A5B2C4E1F33}.Release|Any CPU.Build.0 = Release|Any CPU
{7B46B7B7-DF3F-4F3A-9DC9-E49AF0D97B61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B46B7B7-DF3F-4F3A-9DC9-E49AF0D97B61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B46B7B7-DF3F-4F3A-9DC9-E49AF0D97B61}.Release|Any CPU.ActiveCfg = Release|Any CPU
diff --git a/apps/AR.Iec61850.VirtualRelayLab/AR.Iec61850.VirtualRelayLab.csproj b/apps/AR.Iec61850.VirtualRelayLab/AR.Iec61850.VirtualRelayLab.csproj
new file mode 100644
index 0000000..9cb3a71
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/AR.Iec61850.VirtualRelayLab.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+ Copyright (C) 2026 Mas Ari / masarray
+ GPL-3.0-or-later
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ AR.Iec61850.VirtualRelayLab
+ AR.Iec61850.VirtualRelayLab
+ AR Virtual Relay Lab
+ IEC 61850 Sampled Values virtual overcurrent and earth-fault protection laboratory.
+
+
+
diff --git a/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml b/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml
new file mode 100644
index 0000000..fc9047f
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml.cs b/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml.cs
new file mode 100644
index 0000000..519b20e
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/AlgorithmEditorWindow.xaml.cs
@@ -0,0 +1,219 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace AR.Iec61850.VirtualRelayLab;
+
+public partial class AlgorithmEditorWindow : Window
+{
+ private readonly Dictionary _algorithms = new()
+ {
+ [0] = """
+element "50P-1" {
+
+ input phaseCurrent =
+ max(IA.rms1c, IB.rms1c, IC.rms1c)
+
+ pickup =
+ phaseCurrent >= setting("I>>")
+
+ dropout =
+ phaseCurrent < setting("I>>") * setting("DropoutRatio")
+
+ operate =
+ pickup.persist(setting("Delay"))
+
+ trip =
+ operate && smv.allowsTrip
+}
+""",
+ [1] = """
+element "51P" {
+
+ input phaseCurrent =
+ max(IA.fundamental, IB.fundamental, IC.fundamental)
+
+ multiple =
+ phaseCurrent / setting("Is")
+
+ operateTime =
+ setting("TMS") *
+ (0.14 / (pow(multiple, 0.02) - 1))
+
+ progress =
+ integrate(dt / operateTime)
+ when multiple > 1
+ reset using setting("ResetMode")
+
+ trip =
+ progress >= 1 && smv.allowsTrip
+}
+""",
+ [2] = """
+element "50N" {
+
+ input earthCurrent =
+ select(setting("EarthInput"), measuredIN, IA + IB + IC)
+
+ pickup =
+ abs(earthCurrent.rms1c) >= setting("I0>>")
+
+ operate =
+ pickup.persist(setting("Delay"))
+
+ trip =
+ operate && smv.allowsTrip
+}
+""",
+ [3] = """
+element "51N" {
+
+ input earthCurrent =
+ abs(IA + IB + IC).fundamental
+
+ multiple =
+ earthCurrent / setting("I0s")
+
+ operateTime =
+ setting("TMS") *
+ (0.14 / (pow(multiple, 0.02) - 1))
+
+ progress =
+ integrate(dt / operateTime)
+ when multiple > 1
+
+ trip =
+ progress >= 1 && smv.allowsTrip
+}
+""",
+ [4] = """
+logic "TRIP" {
+
+ request =
+ 50P_1.trip || 51P.trip || 50N.trip || 51N.trip
+
+ permit =
+ smv.allowsTrip && runtime.healthy
+
+ output virtualTrip =
+ latch(request && permit)
+ reset using command("RESET")
+}
+""",
+ [5] = """
+policy "SMV-TRUST" {
+
+ measurement =
+ frame.valid && mapping.known && stream.fresh
+
+ pickup =
+ measurement && quality.accepted
+
+ trip =
+ pickup
+ && smpCnt.continuous
+ && processing.withinBudget
+ && algorithm.validated
+
+ on trip == false {
+ expose blockReason
+ }
+}
+"""
+ };
+
+ public AlgorithmEditorWindow()
+ {
+ InitializeComponent();
+ AlgorithmTextBox.Text = _algorithms[0];
+ }
+
+ private void ElementList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || ElementList.SelectedIndex < 0)
+ return;
+
+ _algorithms[GetPreviousIndex()] = AlgorithmTextBox.Text;
+ AlgorithmTextBox.Text = _algorithms[ElementList.SelectedIndex];
+ EditorTitleText.Text = ElementList.SelectedIndex switch
+ {
+ 0 => "50P-1 · Standard precision profile",
+ 1 => "51P · IEC standard inverse profile",
+ 2 => "50N · Residual / neutral current profile",
+ 3 => "51N · IEC earth-fault inverse profile",
+ 4 => "TRIP · Guarded logic matrix",
+ _ => "SMV · Protection trust policy"
+ };
+ ResetValidationState();
+ _lastSelectedIndex = ElementList.SelectedIndex;
+ }
+
+ private int _lastSelectedIndex;
+
+ private int GetPreviousIndex()
+ {
+ return Math.Clamp(_lastSelectedIndex, 0, _algorithms.Count - 1);
+ }
+
+ private void Validate_Click(object sender, RoutedEventArgs e)
+ {
+ var source = AlgorithmTextBox.Text;
+ var errors = new List();
+
+ if (!source.Contains('{') || !source.Contains('}'))
+ errors.Add("block delimiters are incomplete");
+ if (!source.Contains("trip", StringComparison.OrdinalIgnoreCase))
+ errors.Add("a trip or trip-permission output is required");
+ if (!source.Contains("smv.allowsTrip", StringComparison.Ordinal))
+ errors.Add("mandatory SMV trust gate is missing");
+ if (source.Contains("while", StringComparison.OrdinalIgnoreCase) ||
+ source.Contains("for (", StringComparison.OrdinalIgnoreCase))
+ errors.Add("unbounded loops are not permitted");
+ if (source.Contains("File.", StringComparison.Ordinal) ||
+ source.Contains("Http", StringComparison.OrdinalIgnoreCase) ||
+ source.Contains("Process.", StringComparison.Ordinal))
+ errors.Add("file, network and process access are not permitted");
+
+ if (errors.Count == 0)
+ {
+ ValidationBadge.Background = new SolidColorBrush(Color.FromRgb(234, 245, 236));
+ ValidationBadge.BorderBrush = new SolidColorBrush(Color.FromRgb(185, 216, 191));
+ ValidationText.Foreground = (Brush)FindResource("HealthyBrush");
+ ValidationText.Text = "Validated · safe to stage";
+ StageButton.IsEnabled = true;
+ CursorStatusText.Text = "Syntax, unit contract, bounded runtime and mandatory SMV gate passed.";
+ }
+ else
+ {
+ ValidationBadge.Background = new SolidColorBrush(Color.FromRgb(251, 235, 234));
+ ValidationBadge.BorderBrush = new SolidColorBrush(Color.FromRgb(227, 175, 172));
+ ValidationText.Foreground = (Brush)FindResource("TripBrush");
+ ValidationText.Text = $"Validation failed · {errors.Count} issue(s)";
+ StageButton.IsEnabled = false;
+ CursorStatusText.Text = string.Join("; ", errors);
+ }
+ }
+
+ private void Stage_Click(object sender, RoutedEventArgs e)
+ {
+ _algorithms[ElementList.SelectedIndex] = AlgorithmTextBox.Text;
+ ValidationText.Text = "Staged for deterministic A/B evaluation";
+ StageButton.IsEnabled = false;
+ CursorStatusText.Text = "The active relay algorithm is unchanged. The staged revision is ready for shadow comparison.";
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ private void ResetValidationState()
+ {
+ ValidationBadge.Background = new SolidColorBrush(Color.FromRgb(238, 242, 245));
+ ValidationBadge.BorderBrush = (Brush)FindResource("LineBrush");
+ ValidationText.Foreground = (Brush)FindResource("MutedBrush");
+ ValidationText.Text = "Not validated";
+ StageButton.IsEnabled = false;
+ CursorStatusText.Text = "Deterministic runtime · no file, network, reflection or unmanaged access";
+ }
+}
diff --git a/apps/AR.Iec61850.VirtualRelayLab/App.xaml b/apps/AR.Iec61850.VirtualRelayLab/App.xaml
new file mode 100644
index 0000000..04bd24c
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/App.xaml
@@ -0,0 +1,123 @@
+
+
+ #F2F5F8
+ #FFFFFF
+ #F8FAFC
+ #D8E0E8
+ #17212B
+ #667789
+ #2F6E9C
+ #E8F1F7
+ #4C9B58
+ #C28A2C
+ #C84B48
+ #AEBBC5
+ #8E9EAA
+ #EAF0E9
+ #50637A
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/AR.Iec61850.VirtualRelayLab/App.xaml.cs b/apps/AR.Iec61850.VirtualRelayLab/App.xaml.cs
new file mode 100644
index 0000000..5187fff
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/App.xaml.cs
@@ -0,0 +1,7 @@
+using System.Windows;
+
+namespace AR.Iec61850.VirtualRelayLab;
+
+public partial class App : Application
+{
+}
diff --git a/apps/AR.Iec61850.VirtualRelayLab/Controls/WaveformScope.cs b/apps/AR.Iec61850.VirtualRelayLab/Controls/WaveformScope.cs
new file mode 100644
index 0000000..97e335b
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/Controls/WaveformScope.cs
@@ -0,0 +1,217 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Media;
+
+namespace AR.Iec61850.VirtualRelayLab.Controls;
+
+public sealed class WaveformScope : FrameworkElement
+{
+ public static readonly DependencyProperty PhaseAProperty = DependencyProperty.Register(
+ nameof(PhaseA), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty PhaseBProperty = DependencyProperty.Register(
+ nameof(PhaseB), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty PhaseCProperty = DependencyProperty.Register(
+ nameof(PhaseC), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty ResidualProperty = DependencyProperty.Register(
+ nameof(Residual), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty FrequencyProperty = DependencyProperty.Register(
+ nameof(Frequency), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(50.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty PickupPositionProperty = DependencyProperty.Register(
+ nameof(PickupPosition), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty TripPositionProperty = DependencyProperty.Register(
+ nameof(TripPosition), typeof(double), typeof(WaveformScope),
+ new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public double PhaseA
+ {
+ get => (double)GetValue(PhaseAProperty);
+ set => SetValue(PhaseAProperty, value);
+ }
+
+ public double PhaseB
+ {
+ get => (double)GetValue(PhaseBProperty);
+ set => SetValue(PhaseBProperty, value);
+ }
+
+ public double PhaseC
+ {
+ get => (double)GetValue(PhaseCProperty);
+ set => SetValue(PhaseCProperty, value);
+ }
+
+ public double Residual
+ {
+ get => (double)GetValue(ResidualProperty);
+ set => SetValue(ResidualProperty, value);
+ }
+
+ public double Frequency
+ {
+ get => (double)GetValue(FrequencyProperty);
+ set => SetValue(FrequencyProperty, value);
+ }
+
+ public double PickupPosition
+ {
+ get => (double)GetValue(PickupPositionProperty);
+ set => SetValue(PickupPositionProperty, value);
+ }
+
+ public double TripPosition
+ {
+ get => (double)GetValue(TripPositionProperty);
+ set => SetValue(TripPositionProperty, value);
+ }
+
+ protected override void OnRender(DrawingContext drawingContext)
+ {
+ base.OnRender(drawingContext);
+
+ var width = Math.Max(1, ActualWidth);
+ var height = Math.Max(1, ActualHeight);
+ var plot = new Rect(52, 16, Math.Max(1, width - 70), Math.Max(1, height - 46));
+
+ drawingContext.DrawRoundedRectangle(
+ new SolidColorBrush(Color.FromRgb(15, 24, 32)),
+ new Pen(new SolidColorBrush(Color.FromRgb(52, 68, 80)), 1),
+ new Rect(0.5, 0.5, width - 1, height - 1),
+ 5,
+ 5);
+
+ DrawGrid(drawingContext, plot);
+ DrawMarker(drawingContext, plot, PickupPosition, Color.FromRgb(205, 151, 52), "PICKUP");
+ DrawMarker(drawingContext, plot, TripPosition, Color.FromRgb(215, 78, 72), "TRIP");
+
+ var maxCurrent = Math.Max(1.2, Math.Max(Math.Max(PhaseA, PhaseB), Math.Max(PhaseC, Residual)) * 1.18);
+ DrawTrace(drawingContext, plot, PhaseA, 0.0, maxCurrent, Color.FromRgb(86, 192, 229));
+ DrawTrace(drawingContext, plot, PhaseB, -2.0 * Math.PI / 3.0, maxCurrent, Color.FromRgb(245, 188, 81));
+ DrawTrace(drawingContext, plot, PhaseC, 2.0 * Math.PI / 3.0, maxCurrent, Color.FromRgb(194, 124, 222));
+ DrawTrace(drawingContext, plot, Residual, 0.0, maxCurrent, Color.FromRgb(108, 202, 137), 1.15);
+
+ DrawScale(drawingContext, plot, maxCurrent);
+ DrawLegend(drawingContext, plot);
+ }
+
+ private static void DrawGrid(DrawingContext dc, Rect plot)
+ {
+ var minorPen = new Pen(new SolidColorBrush(Color.FromRgb(31, 45, 56)), 1);
+ var majorPen = new Pen(new SolidColorBrush(Color.FromRgb(45, 62, 74)), 1);
+ var zeroPen = new Pen(new SolidColorBrush(Color.FromRgb(81, 101, 115)), 1);
+
+ for (var column = 0; column <= 16; column++)
+ {
+ var x = plot.Left + plot.Width * column / 16.0;
+ dc.DrawLine(column % 4 == 0 ? majorPen : minorPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
+ }
+
+ for (var row = 0; row <= 8; row++)
+ {
+ var y = plot.Top + plot.Height * row / 8.0;
+ var pen = row == 4 ? zeroPen : row % 2 == 0 ? majorPen : minorPen;
+ dc.DrawLine(pen, new Point(plot.Left, y), new Point(plot.Right, y));
+ }
+ }
+
+ private static void DrawTrace(
+ DrawingContext dc,
+ Rect plot,
+ double amplitude,
+ double phase,
+ double maxCurrent,
+ Color color,
+ double thickness = 1.55)
+ {
+ if (amplitude <= 0.0001)
+ return;
+
+ var geometry = new StreamGeometry();
+ using (var context = geometry.Open())
+ {
+ const int pointCount = 640;
+ for (var index = 0; index < pointCount; index++)
+ {
+ var normalized = index / (double)(pointCount - 1);
+ var x = plot.Left + normalized * plot.Width;
+ var angle = normalized * Math.PI * 4.0 + phase;
+ var normalizedAmplitude = Math.Clamp(amplitude / maxCurrent, 0, 1);
+ var y = plot.Top + plot.Height / 2.0 - Math.Sin(angle) * normalizedAmplitude * plot.Height * 0.42;
+ var point = new Point(x, y);
+ if (index == 0)
+ context.BeginFigure(point, false, false);
+ else
+ context.LineTo(point, true, false);
+ }
+ }
+
+ geometry.Freeze();
+ dc.DrawGeometry(null, new Pen(new SolidColorBrush(color), thickness), geometry);
+ }
+
+ private static void DrawMarker(DrawingContext dc, Rect plot, double normalizedPosition, Color color, string label)
+ {
+ if (double.IsNaN(normalizedPosition) || normalizedPosition < 0 || normalizedPosition > 1)
+ return;
+
+ var x = plot.Left + plot.Width * normalizedPosition;
+ var brush = new SolidColorBrush(color);
+ var pen = new Pen(brush, 1) { DashStyle = DashStyles.Dash };
+ dc.DrawLine(pen, new Point(x, plot.Top), new Point(x, plot.Bottom));
+ dc.DrawText(CreateText(label, 9, brush), new Point(Math.Min(x + 4, plot.Right - 46), plot.Top + 3));
+ }
+
+ private static void DrawScale(DrawingContext dc, Rect plot, double maxCurrent)
+ {
+ var muted = new SolidColorBrush(Color.FromRgb(143, 160, 173));
+ dc.DrawText(CreateText($"+{maxCurrent:0.0} A", 9.5, muted), new Point(4, plot.Top - 3));
+ dc.DrawText(CreateText("0", 9.5, muted), new Point(28, plot.Top + plot.Height / 2.0 - 7));
+ dc.DrawText(CreateText($"-{maxCurrent:0.0} A", 9.5, muted), new Point(4, plot.Bottom - 12));
+ dc.DrawText(CreateText("0 ms", 9.5, muted), new Point(plot.Left, plot.Bottom + 7));
+ dc.DrawText(CreateText("1 cycle", 9.5, muted), new Point(plot.Left + plot.Width / 2.0 - 21, plot.Bottom + 7));
+ dc.DrawText(CreateText("2 cycles", 9.5, muted), new Point(plot.Right - 42, plot.Bottom + 7));
+ }
+
+ private static void DrawLegend(DrawingContext dc, Rect plot)
+ {
+ var entries = new[]
+ {
+ ("IA", Color.FromRgb(86, 192, 229)),
+ ("IB", Color.FromRgb(245, 188, 81)),
+ ("IC", Color.FromRgb(194, 124, 222)),
+ ("3I0", Color.FromRgb(108, 202, 137))
+ };
+
+ var x = plot.Right - 174;
+ foreach (var entry in entries)
+ {
+ var brush = new SolidColorBrush(entry.Item2);
+ dc.DrawLine(new Pen(brush, 2), new Point(x, plot.Top + 10), new Point(x + 15, plot.Top + 10));
+ dc.DrawText(CreateText(entry.Item1, 9.5, brush), new Point(x + 20, plot.Top + 3));
+ x += 43;
+ }
+ }
+
+ private static FormattedText CreateText(string text, double size, Brush brush)
+ {
+ return new FormattedText(
+ text,
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ new Typeface("Cascadia Mono"),
+ size,
+ brush,
+ 1.0);
+ }
+}
diff --git a/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml b/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml
new file mode 100644
index 0000000..25c9fca
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml
@@ -0,0 +1,450 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml.cs b/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml.cs
new file mode 100644
index 0000000..0cbae83
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml.cs
@@ -0,0 +1,285 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using System.Windows.Threading;
+using AR.Iec61850.VirtualRelayLab.Protection;
+
+namespace AR.Iec61850.VirtualRelayLab;
+
+public partial class MainWindow : Window
+{
+ private readonly DispatcherTimer _timer;
+ private readonly ProtectionEngine _protectionEngine = new(new ProtectionSettings());
+ private bool _running;
+ private bool _faultActive;
+ private bool _smvHealthy = true;
+ private int _sampleCounter;
+ private DateTimeOffset _startedAt;
+ private bool _pickupLogged;
+ private bool _tripLogged;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _timer = new DispatcherTimer(DispatcherPriority.Send)
+ {
+ Interval = TimeSpan.FromMilliseconds(20)
+ };
+ _timer.Tick += Timer_Tick;
+ SetHealthyPresentation();
+ UpdateMeasurements(1.00, 1.00, 1.00, 0.00);
+ }
+
+ private void RunButton_Click(object sender, RoutedEventArgs e)
+ {
+ _running = !_running;
+ if (_running)
+ {
+ _startedAt = DateTimeOffset.UtcNow;
+ _timer.Start();
+ RunButton.Content = "Pause lab";
+ FooterStatusText.Text = "Protection pipeline running from deterministic SMV sample frames.";
+ EventTraceText.Text = "RUN Measurement pipeline started\nHEALTHY SMV trust gate permits trip";
+ }
+ else
+ {
+ _timer.Stop();
+ RunButton.Content = "Run lab";
+ FooterStatusText.Text = "Laboratory paused. The two-cycle oscilloscope snapshot remains available for inspection.";
+ }
+ }
+
+ private void InjectFault_Click(object sender, RoutedEventArgs e)
+ {
+ EnsureRunning();
+ _faultActive = true;
+ FooterStatusText.Text = "A-G fault injected. Observe IA and 3I0, protection pickup, operate delay, then virtual trip.";
+ AppendEvent("FAULT A-G inception");
+ }
+
+ private void DegradeSmv_Click(object sender, RoutedEventArgs e)
+ {
+ EnsureRunning();
+ _smvHealthy = !_smvHealthy;
+ if (_smvHealthy)
+ {
+ AppendEvent("SMV continuity restored");
+ FooterStatusText.Text = "SMV continuity restored. Trip permission is available after healthy measurement refresh.";
+ }
+ else
+ {
+ AppendEvent("SMV BLOCK smpCnt discontinuity");
+ FooterStatusText.Text = "SMV degraded. Protection remains visible, but the trip trust gate is blocked.";
+ }
+ }
+
+ private void Reset_Click(object sender, RoutedEventArgs e)
+ {
+ _faultActive = false;
+ _smvHealthy = true;
+ _sampleCounter = 0;
+ _pickupLogged = false;
+ _tripLogged = false;
+ _protectionEngine.Reset();
+ SmvScope.PickupPosition = double.NaN;
+ SmvScope.TripPosition = double.NaN;
+ EventTraceText.Text = "RESET Trip latch and timers cleared\nHEALTHY SMV trust gate permits trip";
+ FooterStatusText.Text = "Relay reset complete. The laboratory is ready for another deterministic test.";
+ UpdateMeasurements(1.00, 1.00, 1.00, 0.00);
+ UpdateProtectionPresentation(new ProtectionSnapshot(
+ false, false, false, false, false, false,
+ "READY", "Measurements stable · no pickup", 0, 0, 1, 0));
+ }
+
+ private void OpenAlgorithmEditor_Click(object sender, RoutedEventArgs e)
+ {
+ var editor = new AlgorithmEditorWindow
+ {
+ Owner = this
+ };
+ editor.ShowDialog();
+ }
+
+ private void Timer_Tick(object? sender, EventArgs e)
+ {
+ _sampleCounter = (_sampleCounter + 80) % 4000;
+ var elapsed = (DateTimeOffset.UtcNow - _startedAt).TotalSeconds;
+ var breathing = Math.Sin(elapsed * 1.7) * 0.008;
+
+ var ia = _faultActive ? 6.40 + Math.Sin(elapsed * 2.3) * 0.03 : 1.00 + breathing;
+ var ib = _faultActive ? 1.04 + breathing : 1.00 - breathing * 0.6;
+ var ic = _faultActive ? 1.01 - breathing : 1.00 + breathing * 0.4;
+ var residual = _faultActive ? 5.36 + Math.Sin(elapsed * 2.1) * 0.02 : 0.01;
+
+ var frame = new MeasurementFrame(
+ DateTimeOffset.UtcNow,
+ ia,
+ ib,
+ ic,
+ residual,
+ _smvHealthy,
+ _smvHealthy ? "SMV HEALTHY" : "SMPCNT GAP / STREAM UNTRUSTED");
+
+ var snapshot = _protectionEngine.Evaluate(frame);
+ UpdateMeasurements(ia, ib, ic, residual);
+ UpdateProtectionPresentation(snapshot);
+
+ if (snapshot.PhasePickup || snapshot.EarthPickup)
+ {
+ if (!_pickupLogged)
+ {
+ _pickupLogged = true;
+ SmvScope.PickupPosition = 0.56;
+ AppendEvent($"PICKUP {snapshot.ActiveElement}");
+ }
+ }
+
+ if (snapshot.TripLatched && !_tripLogged)
+ {
+ _tripLogged = true;
+ SmvScope.TripPosition = 0.72;
+ AppendEvent($"TRIP {snapshot.ActiveElement}");
+ }
+ }
+
+ private void EnsureRunning()
+ {
+ if (_running)
+ return;
+
+ _running = true;
+ _startedAt = DateTimeOffset.UtcNow;
+ _timer.Start();
+ RunButton.Content = "Pause lab";
+ }
+
+ private void UpdateMeasurements(double ia, double ib, double ic, double residual)
+ {
+ SmvScope.PhaseA = ia;
+ SmvScope.PhaseB = ib;
+ SmvScope.PhaseC = ic;
+ SmvScope.Residual = residual;
+
+ IaValueText.Text = $"{ia:0.00} A";
+ IbValueText.Text = $"{ib:0.00} A";
+ IcValueText.Text = $"{ic:0.00} A";
+ ResidualValueText.Text = $"{residual:0.00} A";
+ LcdIaText.Text = $"{ia,6:0.00} A";
+ LcdIbText.Text = $"{ib,6:0.00} A";
+ LcdIcText.Text = $"{ic,6:0.00} A";
+ LcdResidualText.Text = $"{residual,6:0.00} A";
+ SampleCounterText.Text = $" · smpCnt {_sampleCounter:0000}";
+ }
+
+ private void UpdateProtectionPresentation(ProtectionSnapshot snapshot)
+ {
+ ProtectionReasonText.Text = $" · {snapshot.DecisionReason}";
+ ActiveElementText.Text = snapshot.ActiveElement;
+
+ var pickup = snapshot.PhasePickup || snapshot.EarthPickup;
+ SetLed(PickupLed, pickup ? Indicator.Warning : Indicator.Off);
+ SetLed(TripLed, snapshot.TripLatched ? Indicator.Trip : Indicator.Off);
+ SetLed(PhaseALed, snapshot.PhasePickup ? Indicator.Warning : Indicator.Off);
+ SetLed(PhaseBLed, Indicator.Off);
+ SetLed(PhaseCLed, Indicator.Off);
+ SetLed(EarthLed, snapshot.EarthPickup ? Indicator.Warning : Indicator.Off);
+ SetLed(BlockedLed, snapshot.Blocked || !_smvHealthy ? Indicator.Warning : Indicator.Off);
+
+ Phase50StateText.Text = snapshot.PhaseTrip ? "OPERATED" : snapshot.PhasePickup ? "PICKUP" : "READY";
+ Phase51StateText.Text = snapshot.PhaseTimeProgress >= 1 ? "OPERATED" : snapshot.PhaseTimeProgress > 0 ? $"TIMING {snapshot.PhaseTimeProgress:P0}" : "READY";
+ Earth50StateText.Text = snapshot.EarthTrip ? "OPERATED" : snapshot.EarthPickup ? "PICKUP" : "READY";
+ Earth51StateText.Text = snapshot.EarthTimeProgress >= 1 ? "OPERATED" : snapshot.EarthTimeProgress > 0 ? $"TIMING {snapshot.EarthTimeProgress:P0}" : "READY";
+
+ Phase50Progress.Value = snapshot.PhasePickup ? snapshot.PhaseTrip ? 100 : 70 : 0;
+ Phase51Progress.Value = snapshot.PhaseTimeProgress * 100;
+ Earth50Progress.Value = snapshot.EarthPickup ? snapshot.EarthTrip ? 100 : 70 : 0;
+ Earth51Progress.Value = snapshot.EarthTimeProgress * 100;
+
+ var stateBrush = snapshot.TripLatched
+ ? FindBrush("TripBrush")
+ : pickup
+ ? FindBrush("WarningBrush")
+ : FindBrush("MutedBrush");
+ Phase50StateText.Foreground = stateBrush;
+ Phase51StateText.Foreground = stateBrush;
+ Earth50StateText.Foreground = stateBrush;
+ Earth51StateText.Foreground = stateBrush;
+
+ if (_smvHealthy)
+ {
+ SetHealthyPresentation();
+ PermissionBadge.Background = new SolidColorBrush(Color.FromRgb(234, 245, 236));
+ PermissionBadge.BorderBrush = new SolidColorBrush(Color.FromRgb(185, 216, 191));
+ PermissionText.Text = "TRIP PERMITTED";
+ PermissionText.Foreground = FindBrush("HealthyBrush");
+ TrustStateText.Text = "HEALTHY";
+ TrustStateText.Foreground = new SolidColorBrush(Color.FromRgb(49, 94, 64));
+ TrustDetailText.Text = "continuous · mapped\nsmpSynch 2 · fresh";
+ }
+ else
+ {
+ SetLed(HealthyLed, Indicator.Warning);
+ SetLed(TopHealthLed, Indicator.Warning);
+ TopHealthText.Text = "SMV DEGRADED";
+ PermissionBadge.Background = new SolidColorBrush(Color.FromRgb(250, 242, 225));
+ PermissionBadge.BorderBrush = new SolidColorBrush(Color.FromRgb(224, 195, 136));
+ PermissionText.Text = "TRIP BLOCKED";
+ PermissionText.Foreground = FindBrush("WarningBrush");
+ TrustStateText.Text = "BLOCKED";
+ TrustStateText.Foreground = FindBrush("WarningBrush");
+ TrustDetailText.Text = "smpCnt discontinuity\nmeasurement visible only";
+ }
+
+ LcdStatusText.Text = snapshot.Blocked
+ ? "BLOCKED · SMV UNTRUSTED"
+ : snapshot.TripLatched
+ ? $"TRIP · {snapshot.ActiveElement}"
+ : pickup
+ ? $"PICKUP · {snapshot.ActiveElement}"
+ : "READY · SMV HEALTHY";
+ }
+
+ private void SetHealthyPresentation()
+ {
+ SetLed(HealthyLed, Indicator.Healthy);
+ SetLed(TopHealthLed, Indicator.Healthy);
+ TopHealthText.Text = "LAB READY";
+ }
+
+ private void AppendEvent(string line)
+ {
+ var lines = EventTraceText.Text
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .TakeLast(3)
+ .ToList();
+ lines.Add(line);
+ EventTraceText.Text = string.Join(Environment.NewLine, lines.TakeLast(4));
+ }
+
+ private Brush FindBrush(string resourceKey)
+ {
+ return (Brush)FindResource(resourceKey);
+ }
+
+ private static void SetLed(Ellipse led, Indicator indicator)
+ {
+ led.Fill = indicator switch
+ {
+ Indicator.Healthy => new SolidColorBrush(Color.FromRgb(84, 174, 94)),
+ Indicator.Warning => new SolidColorBrush(Color.FromRgb(218, 157, 48)),
+ Indicator.Trip => new SolidColorBrush(Color.FromRgb(214, 70, 66)),
+ _ => new SolidColorBrush(Color.FromRgb(83, 97, 107))
+ };
+ led.Stroke = indicator == Indicator.Off
+ ? new SolidColorBrush(Color.FromRgb(107, 123, 134))
+ : new SolidColorBrush(Color.FromRgb(238, 245, 239));
+ }
+
+ private enum Indicator
+ {
+ Off,
+ Healthy,
+ Warning,
+ Trip
+ }
+}
diff --git a/apps/AR.Iec61850.VirtualRelayLab/Protection/ProtectionEngine.cs b/apps/AR.Iec61850.VirtualRelayLab/Protection/ProtectionEngine.cs
new file mode 100644
index 0000000..798a89e
--- /dev/null
+++ b/apps/AR.Iec61850.VirtualRelayLab/Protection/ProtectionEngine.cs
@@ -0,0 +1,184 @@
+namespace AR.Iec61850.VirtualRelayLab.Protection;
+
+public sealed record ProtectionSettings
+{
+ public double PhaseInstantaneousPickupA { get; init; } = 4.0;
+ public TimeSpan PhaseInstantaneousDelay { get; init; } = TimeSpan.FromMilliseconds(60);
+ public double PhaseTimePickupA { get; init; } = 1.25;
+ public double PhaseTimeMultiplier { get; init; } = 0.12;
+ public double EarthInstantaneousPickupA { get; init; } = 0.80;
+ public TimeSpan EarthInstantaneousDelay { get; init; } = TimeSpan.FromMilliseconds(100);
+ public double EarthTimePickupA { get; init; } = 0.30;
+ public double EarthTimeMultiplier { get; init; } = 0.15;
+ public double DropoutRatio { get; init; } = 0.95;
+}
+
+public sealed record MeasurementFrame(
+ DateTimeOffset Timestamp,
+ double PhaseA,
+ double PhaseB,
+ double PhaseC,
+ double Residual,
+ bool SmvAllowsTrip,
+ string SmvHealthReason);
+
+public sealed record ProtectionSnapshot(
+ bool PhasePickup,
+ bool EarthPickup,
+ bool PhaseTrip,
+ bool EarthTrip,
+ bool TripLatched,
+ bool Blocked,
+ string ActiveElement,
+ string DecisionReason,
+ double PhaseTimeProgress,
+ double EarthTimeProgress,
+ double MaxPhaseCurrent,
+ double ResidualCurrent);
+
+public sealed class ProtectionEngine
+{
+ private readonly ProtectionSettings _settings;
+ private TimeSpan _phaseInstantaneousElapsed;
+ private TimeSpan _earthInstantaneousElapsed;
+ private double _phaseInverseProgress;
+ private double _earthInverseProgress;
+ private DateTimeOffset? _previousTimestamp;
+ private bool _tripLatched;
+
+ public ProtectionEngine(ProtectionSettings settings)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ }
+
+ public ProtectionSnapshot Evaluate(MeasurementFrame frame)
+ {
+ var delta = _previousTimestamp is null
+ ? TimeSpan.Zero
+ : frame.Timestamp - _previousTimestamp.Value;
+ _previousTimestamp = frame.Timestamp;
+
+ if (delta < TimeSpan.Zero || delta > TimeSpan.FromSeconds(1))
+ delta = TimeSpan.Zero;
+
+ var maxPhase = Math.Max(frame.PhaseA, Math.Max(frame.PhaseB, frame.PhaseC));
+ var phase50Pickup = maxPhase >= _settings.PhaseInstantaneousPickupA;
+ var earth50Pickup = frame.Residual >= _settings.EarthInstantaneousPickupA;
+ var phase51Pickup = maxPhase >= _settings.PhaseTimePickupA;
+ var earth51Pickup = frame.Residual >= _settings.EarthTimePickupA;
+
+ _phaseInstantaneousElapsed = AccumulateDefiniteTime(
+ _phaseInstantaneousElapsed,
+ phase50Pickup,
+ delta,
+ maxPhase < _settings.PhaseInstantaneousPickupA * _settings.DropoutRatio);
+
+ _earthInstantaneousElapsed = AccumulateDefiniteTime(
+ _earthInstantaneousElapsed,
+ earth50Pickup,
+ delta,
+ frame.Residual < _settings.EarthInstantaneousPickupA * _settings.DropoutRatio);
+
+ _phaseInverseProgress = AccumulateInverse(
+ _phaseInverseProgress,
+ maxPhase,
+ _settings.PhaseTimePickupA,
+ _settings.PhaseTimeMultiplier,
+ delta,
+ phase51Pickup);
+
+ _earthInverseProgress = AccumulateInverse(
+ _earthInverseProgress,
+ frame.Residual,
+ _settings.EarthTimePickupA,
+ _settings.EarthTimeMultiplier,
+ delta,
+ earth51Pickup);
+
+ var phase50Operate = _phaseInstantaneousElapsed >= _settings.PhaseInstantaneousDelay;
+ var earth50Operate = _earthInstantaneousElapsed >= _settings.EarthInstantaneousDelay;
+ var phase51Operate = _phaseInverseProgress >= 1.0;
+ var earth51Operate = _earthInverseProgress >= 1.0;
+
+ var phaseOperate = phase50Operate || phase51Operate;
+ var earthOperate = earth50Operate || earth51Operate;
+ var blocked = (phaseOperate || earthOperate) && !frame.SmvAllowsTrip;
+
+ if ((phaseOperate || earthOperate) && frame.SmvAllowsTrip)
+ _tripLatched = true;
+
+ var activeElement = phase50Operate ? "50P-1"
+ : earth50Operate ? "50N"
+ : phase51Operate ? "51P"
+ : earth51Operate ? "51N"
+ : phase50Pickup ? "50P-1 PICKUP"
+ : earth50Pickup ? "50N PICKUP"
+ : phase51Pickup ? "51P START"
+ : earth51Pickup ? "51N START"
+ : "READY";
+
+ var reason = blocked
+ ? $"TRIP BLOCKED · {frame.SmvHealthReason}"
+ : _tripLatched
+ ? $"TRIP LATCHED · {activeElement}"
+ : phase50Pickup || earth50Pickup || phase51Pickup || earth51Pickup
+ ? $"OPERATING · {activeElement}"
+ : "Measurements stable · no pickup";
+
+ return new ProtectionSnapshot(
+ phase50Pickup || phase51Pickup,
+ earth50Pickup || earth51Pickup,
+ phaseOperate && frame.SmvAllowsTrip,
+ earthOperate && frame.SmvAllowsTrip,
+ _tripLatched,
+ blocked,
+ activeElement,
+ reason,
+ Math.Clamp(_phaseInverseProgress, 0, 1),
+ Math.Clamp(_earthInverseProgress, 0, 1),
+ maxPhase,
+ frame.Residual);
+ }
+
+ public void Reset()
+ {
+ _phaseInstantaneousElapsed = TimeSpan.Zero;
+ _earthInstantaneousElapsed = TimeSpan.Zero;
+ _phaseInverseProgress = 0;
+ _earthInverseProgress = 0;
+ _previousTimestamp = null;
+ _tripLatched = false;
+ }
+
+ private static TimeSpan AccumulateDefiniteTime(
+ TimeSpan current,
+ bool pickup,
+ TimeSpan delta,
+ bool droppedOut)
+ {
+ if (pickup)
+ return current + delta;
+ return droppedOut ? TimeSpan.Zero : current;
+ }
+
+ private static double AccumulateInverse(
+ double current,
+ double measured,
+ double pickup,
+ double timeMultiplier,
+ TimeSpan delta,
+ bool started)
+ {
+ if (!started || pickup <= 0 || measured <= pickup)
+ return Math.Max(0, current - delta.TotalSeconds * 2.0);
+
+ var multiple = measured / pickup;
+ var denominator = Math.Pow(multiple, 0.02) - 1.0;
+ if (denominator <= 0.000001)
+ return current;
+
+ var operateSeconds = timeMultiplier * 0.14 / denominator;
+ operateSeconds = Math.Clamp(operateSeconds, 0.02, 120.0);
+ return current + delta.TotalSeconds / operateSeconds;
+ }
+}
diff --git a/docs/VIRTUAL_RELAY_LAB_PRD.md b/docs/VIRTUAL_RELAY_LAB_PRD.md
new file mode 100644
index 0000000..ef39376
--- /dev/null
+++ b/docs/VIRTUAL_RELAY_LAB_PRD.md
@@ -0,0 +1,349 @@
+# AR Virtual Relay Lab — Product Requirements Document
+
+Status: execution baseline
+Target: Windows 10/11 x64, .NET 8 WPF
+License: GPL-3.0-or-later
+Primary engine: ARIEC61850
+Subscriber UX and workflow reference: ARSVIN / ArSubsv
+
+## Product statement
+
+AR Virtual Relay Lab is a process-bus protection engineering workbench in which every protection decision is traceable from an incoming IEC 61850 Sampled Values stream through measurement, protection logic, trust gating, pickup, operate and virtual trip.
+
+It is not a dashboard, a vendor-relay clone, a certified protection IED, a conformance test set, or a physical trip device.
+
+## Product promise
+
+The main workspace must show the complete cause-and-effect chain without vertical page scrolling:
+
+```text
+SMV waveform changes
+→ operating quantity changes
+→ protection element picks up
+→ timer or inverse accumulator advances
+→ trip permission is evaluated
+→ virtual relay operates or displays an explicit block reason
+```
+
+The same workspace must make communication failure equally understandable:
+
+```text
+SMV integrity degrades
+→ trust guard identifies the exact reason
+→ measurement remains visible when safe to inspect
+→ trip permission is removed
+→ no silent or false trip is produced
+```
+
+## Users
+
+- protection and control engineers;
+- substation automation and IEC 61850 engineers;
+- FAT, SAT and commissioning engineers;
+- lecturers, students and protection researchers;
+- engineers developing or comparing numerical-relay algorithms.
+
+## V1 scope
+
+### Inputs
+
+- live IEC 61850 Sampled Values capture through the ARIEC61850 Npcap transport;
+- classic PCAP replay;
+- deterministic internal waveform and fault scenarios;
+- SCL/SCD/CID/IID-assisted stream binding;
+- manual mapping when engineering metadata is unavailable;
+- primary and secondary CT measurement context.
+
+### Protection
+
+- 50P phase instantaneous overcurrent;
+- 51P phase time overcurrent;
+- 50N/50G instantaneous earth fault;
+- 51N/51G time earth fault;
+- phase-segregated pickup indication;
+- common guarded virtual-trip matrix;
+- definite-time and IEC inverse-time operation;
+- dropout, reset, memory and latch behavior;
+- per-element editable algorithm contract.
+
+### Evidence
+
+- sequence of events;
+- sample-counter correlation;
+- source, stream and SCL identity;
+- settings snapshot;
+- active algorithm identity and content hash;
+- pickup, operate, trip and blocking reasons;
+- waveform and protection trace export;
+- optional source-PCAP correlation.
+
+## Out of scope for V1
+
+- physical output contacts;
+- active GOOSE trip output;
+- MMS breaker control;
+- operational-substation deployment;
+- protection certification or IEC 61850 conformance claim;
+- vendor firmware, vendor algorithm or vendor faceplate replication;
+- distance, differential, directional and autoreclose protection.
+
+## UX contract
+
+### Visual character
+
+The application must be clean, lean and industrial rather than decorative:
+
+- compact one-screen engineering workspace;
+- neutral off-white shell and graphite text;
+- restrained blue selection accent;
+- green used only for healthy or permitted states;
+- amber used only for pickup, caution and blocking;
+- red used only for trip or error;
+- thin dividers, small radii and minimal shadows;
+- no glassmorphism, oversized typography, bulky cards or excessive bold text;
+- engineering values use a monospaced typeface;
+- the virtual relay is original and vendor-neutral.
+
+### Main workspace
+
+Optimized viewport: 1440 × 900.
+Minimum supported viewport: 1280 × 720.
+
+```text
+┌─────────────────────────────────────────────────────────────────────┐
+│ product identity │ source │ stream │ SCL │ algorithm editor │ run │
+├───────────────────────────────────────────┬─────────────────────────┤
+│ SMV waveform — fixed two-cycle window │ original virtual relay │
+│ IA / IB / IC / 3I0 │ LCD measurements │
+│ pickup and trip markers │ healthy/pickup/trip LEDs │
+│ │ phase and earth LEDs │
+├───────────────────────────────────────────┤ trust and block reason │
+│ protection element causality strip │ keypad and reset │
+│ 50P · 51P · 50N · 51N · event trace │ virtual-output boundary │
+└───────────────────────────────────────────┴─────────────────────────┘
+```
+
+The left workspace receives approximately 64–66% of width; the relay receives 34–36%. Splitters may resize the areas, but the relay must remain fully visible.
+
+### Waveform behavior
+
+- exactly two cycles based on estimated or configured frequency;
+- stationary oscilloscope view rather than a continuously travelling decorative waveform;
+- rolling sample replacement may occur internally, but the displayed time reference remains fixed;
+- fixed fault-inception, pickup and trip markers;
+- IA, IB, IC and 3I0 traces;
+- primary/secondary view selection;
+- freeze, triggered and trip-centred modes in later V1 increments;
+- rendering refresh is independent from protection calculation.
+
+### Virtual relay behavior
+
+The relay faceplate is constructed from vector controls, not a vendor image. It includes:
+
+- neutral device identity and laboratory serial;
+- LCD pages for current, protection, process-bus health, active blocking and last event;
+- Healthy, Pickup, Trip, Phase A/B/C, Earth Fault and Block indicators;
+- navigation controls and explicit reset;
+- persistent trip latch until reset;
+- no network or physical output in V1.
+
+## Architecture
+
+```text
+WPF application shell
+ ├── workspace and virtual-relay presentation
+ ├── project/scenario orchestration
+ ├── evidence recorder
+ └── immutable UI snapshots
+ ↑
+Protection engine
+ ├── 50P / 51P / 50N / 51N
+ ├── timers and inverse accumulators
+ ├── trip matrix and latch
+ └── algorithm runtime contract
+ ↑
+Measurement pipeline
+ ├── channel mapping and scaling
+ ├── RMS/fundamental extraction
+ ├── residual current and frequency
+ └── circular disturbance buffer
+ ↑
+SMV trust guard
+ ├── stream identity and freshness
+ ├── smpCnt continuity and duplicates
+ ├── sample rate and processing budget
+ ├── quality and synchronization policy
+ └── allowsMeasurement / allowsPickup / allowsTrip
+ ↑
+ARIEC61850
+ ├── Sampled Values codec and profiles
+ ├── SCL
+ ├── PCAP
+ └── Npcap transport
+```
+
+Protection evaluation must never depend on UI refresh cadence. Capture, decoding, measurement, protection, evidence and UI presentation are separate bounded work domains.
+
+## SMV trust model
+
+The trust guard produces three explicit permissions:
+
+```text
+AllowsMeasurement
+AllowsPickup
+AllowsTrip
+```
+
+Default behavior is conservative and explainable. A condition may remain measurable while trip is blocked, but the block reason must be visible and recorded.
+
+Minimum supervised conditions:
+
+- APPID, destination MAC, VLAN and svID identity;
+- confRev change;
+- payload mapping and scaling;
+- smpCnt continuity, duplicate and reorder;
+- sample-rate stability;
+- frame freshness and stream stall;
+- quality policy;
+- synchronization state;
+- decode and processing overrun;
+- algorithm runtime health.
+
+## Measurement model
+
+Canonical current signals:
+
+```text
+IA
+IB
+IC
+IN
+3I0 = IA + IB + IC
+```
+
+Each signal retains raw representation, scale, engineering unit, secondary value, primary value, CT ratio, mapping source and quality.
+
+Built-in measurement choices:
+
+- full-cycle RMS;
+- half-cycle RMS;
+- sliding RMS;
+- full-cycle fundamental Fourier;
+- half-cycle fundamental Fourier;
+- custom typed measurement algorithm.
+
+## Protection contract
+
+### 50P and 50N
+
+- configurable pickup;
+- minimum persistence and definite delay;
+- dropout ratio and reset delay;
+- phase or residual source selection;
+- mandatory trust permission before virtual trip.
+
+### 51P and 51N
+
+- definite time;
+- IEC Standard, Very, Extremely and Long-Time Inverse;
+- numerical time integration rather than a one-shot timer;
+- instantaneous, definite, inverse or memory reset;
+- independently configured phase and earth-fault settings.
+
+## Algorithm Editor
+
+Algorithm editing is per element and uses a typed, unit-aware protection DSL. Arbitrary C# execution is not the standard mode.
+
+Activation workflow:
+
+```text
+edit
+→ syntax and unit validation
+→ bounded-runtime analysis
+→ deterministic tests
+→ reference/custom comparison
+→ stage
+→ shadow evaluation
+→ explicit activation
+```
+
+Safe Laboratory Mode enforces `smv.allowsTrip` as a mandatory final gate. Algorithms cannot access files, networks, processes, reflection, unmanaged code, UI objects or unbounded loops. Every staged or active revision has a version and content hash.
+
+Example:
+
+```text
+element "50P-1" {
+ input phaseCurrent = max(IA.rms1c, IB.rms1c, IC.rms1c)
+ pickup = phaseCurrent >= setting("I>>")
+ operate = pickup.persist(setting("Delay"))
+ trip = operate && smv.allowsTrip
+}
+```
+
+## Deterministic scenario coverage
+
+- balanced load;
+- load step and overload;
+- three-phase, phase-phase and phase-earth fault;
+- inception-angle and DC-offset variation;
+- harmonics, noise and CT-saturation approximation;
+- frequency deviation and current ramp;
+- isolated gap, burst loss, duplicate and reorder;
+- stale stream, freeze, APPID/svID/confRev change;
+- quality invalid and synchronization loss;
+- processing overrun and algorithm runtime failure.
+
+## Acceptance criteria
+
+- the main workspace requires no vertical page scrolling at 1280 × 720;
+- the waveform represents a fixed two-cycle timebase;
+- live, replay and simulated sources enter the same measurement/protection contract;
+- 50/51 phase and earth-fault outputs are generated only from measurement frames;
+- repeated replay of identical evidence produces identical event ordering and operation time within the defined tolerance;
+- uncertain input removes trip permission and displays the exact reason;
+- the UI cannot stall or change protection timing;
+- algorithm source with errors or missing trust gate cannot be staged;
+- trip indication remains latched until reset;
+- no active network or physical trip output exists in V1;
+- product artwork and naming remain original and vendor-neutral.
+
+## Delivery sequence
+
+### P0 — implemented shell
+
+- original premium one-screen WPF workspace;
+- stationary two-cycle waveform control;
+- neutral virtual-relay faceplate;
+- deterministic current frames;
+- basic 50/51 phase and earth-fault core;
+- SMV trip-permission demonstration;
+- pickup, trip, block and reset causality;
+- focused algorithm-editor prototype.
+
+### P1 — real subscriber pipeline
+
+- extract and integrate ArSubsv live capture and PCAP replay workflow;
+- SCL stream binding and channel mapping;
+- measurement context and scaling;
+- real circular sample buffers;
+- deterministic protection tests and evidence events.
+
+### P2 — complete algorithm laboratory
+
+- typed DSL parser, type system and bytecode/interpreter;
+- unit and static-safety analysis;
+- shadow A/B execution;
+- reference comparison and activation control;
+- versioned algorithm packages and content hashes.
+
+### P3 — evidence and release quality
+
+- disturbance recorder and COMTRADE workflow;
+- JSON/Markdown evidence pack;
+- performance and replay benchmarks;
+- installer, portable package, SBOM and checksums;
+- professional documentation and GitHub Pages product site.
+
+## Safety statement
+
+AR Virtual Relay Lab is an engineering and educational laboratory. It is not a certified relay, deterministic real-time protection platform or authorization to operate primary equipment. V1 produces virtual trip indications only.
diff --git a/tests/AR.Iec61850.VirtualRelayLab.Tests/AR.Iec61850.VirtualRelayLab.Tests.csproj b/tests/AR.Iec61850.VirtualRelayLab.Tests/AR.Iec61850.VirtualRelayLab.Tests.csproj
new file mode 100644
index 0000000..23f6ceb
--- /dev/null
+++ b/tests/AR.Iec61850.VirtualRelayLab.Tests/AR.Iec61850.VirtualRelayLab.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Copyright (C) 2026 Mas Ari / masarray
+ GPL-3.0-or-later
+ net8.0-windows
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AR.Iec61850.VirtualRelayLab.Tests/ProtectionEngineTests.cs b/tests/AR.Iec61850.VirtualRelayLab.Tests/ProtectionEngineTests.cs
new file mode 100644
index 0000000..4954953
--- /dev/null
+++ b/tests/AR.Iec61850.VirtualRelayLab.Tests/ProtectionEngineTests.cs
@@ -0,0 +1,218 @@
+using AR.Iec61850.VirtualRelayLab.Protection;
+
+namespace AR.Iec61850.VirtualRelayLab.Tests;
+
+public sealed class ProtectionEngineTests
+{
+ [Fact]
+ public void NormalLoad_DoesNotPickupOrTrip()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings());
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var snapshot = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromMilliseconds(500),
+ phaseA: 1.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.01,
+ allowsTrip: true);
+
+ Assert.False(snapshot.PhasePickup);
+ Assert.False(snapshot.EarthPickup);
+ Assert.False(snapshot.TripLatched);
+ Assert.False(snapshot.Blocked);
+ }
+
+ [Fact]
+ public void PhaseInstantaneousFault_TripsAfterConfiguredDelay()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings
+ {
+ PhaseInstantaneousPickupA = 4.0,
+ PhaseInstantaneousDelay = TimeSpan.FromMilliseconds(60)
+ });
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var beforeDelay = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromMilliseconds(40),
+ phaseA: 6.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.0,
+ allowsTrip: true);
+
+ var afterDelay = EvaluateFor(
+ engine,
+ start.AddMilliseconds(60),
+ TimeSpan.FromMilliseconds(80),
+ phaseA: 6.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.0,
+ allowsTrip: true);
+
+ Assert.True(beforeDelay.PhasePickup);
+ Assert.False(beforeDelay.TripLatched);
+ Assert.True(afterDelay.PhaseTrip);
+ Assert.True(afterDelay.TripLatched);
+ Assert.Equal("50P-1", afterDelay.ActiveElement);
+ }
+
+ [Fact]
+ public void OperateCondition_IsBlockedWhenSmvTrustDeniesTrip()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings
+ {
+ PhaseInstantaneousPickupA = 4.0,
+ PhaseInstantaneousDelay = TimeSpan.FromMilliseconds(40)
+ });
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var snapshot = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromMilliseconds(120),
+ phaseA: 6.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.0,
+ allowsTrip: false,
+ healthReason: "SMPCNT GAP");
+
+ Assert.True(snapshot.PhasePickup);
+ Assert.True(snapshot.Blocked);
+ Assert.False(snapshot.PhaseTrip);
+ Assert.False(snapshot.TripLatched);
+ Assert.Contains("SMPCNT GAP", snapshot.DecisionReason, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EarthFault_OperatesIndependentEarthElement()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings
+ {
+ EarthInstantaneousPickupA = 0.8,
+ EarthInstantaneousDelay = TimeSpan.FromMilliseconds(80)
+ });
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var snapshot = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromMilliseconds(160),
+ phaseA: 1.1,
+ phaseB: 1.0,
+ phaseC: 0.9,
+ residual: 1.2,
+ allowsTrip: true);
+
+ Assert.True(snapshot.EarthPickup);
+ Assert.True(snapshot.EarthTrip);
+ Assert.True(snapshot.TripLatched);
+ Assert.Equal("50N", snapshot.ActiveElement);
+ }
+
+ [Fact]
+ public void PhaseInverseElement_AccumulatesAndTripsBelowInstantaneousPickup()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings
+ {
+ PhaseInstantaneousPickupA = 10.0,
+ PhaseTimePickupA = 1.25,
+ PhaseTimeMultiplier = 0.12
+ });
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var snapshot = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromSeconds(2.5),
+ phaseA: 2.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.0,
+ allowsTrip: true);
+
+ Assert.True(snapshot.PhasePickup);
+ Assert.True(snapshot.PhaseTrip);
+ Assert.True(snapshot.TripLatched);
+ Assert.Equal("51P", snapshot.ActiveElement);
+ Assert.Equal(1.0, snapshot.PhaseTimeProgress, 6);
+ }
+
+ [Fact]
+ public void Reset_ClearsLatchedTripAndTimingState()
+ {
+ var engine = new ProtectionEngine(new ProtectionSettings
+ {
+ PhaseInstantaneousDelay = TimeSpan.FromMilliseconds(40)
+ });
+ var start = DateTimeOffset.Parse("2026-07-31T00:00:00Z");
+
+ var operated = EvaluateFor(
+ engine,
+ start,
+ TimeSpan.FromMilliseconds(100),
+ phaseA: 6.0,
+ phaseB: 1.0,
+ phaseC: 1.0,
+ residual: 0.0,
+ allowsTrip: true);
+ Assert.True(operated.TripLatched);
+
+ engine.Reset();
+ var resetSnapshot = engine.Evaluate(new MeasurementFrame(
+ start.AddSeconds(1),
+ 1.0,
+ 1.0,
+ 1.0,
+ 0.0,
+ true,
+ "SMV HEALTHY"));
+
+ Assert.False(resetSnapshot.PhasePickup);
+ Assert.False(resetSnapshot.TripLatched);
+ Assert.Equal(0.0, resetSnapshot.PhaseTimeProgress, 6);
+ }
+
+ private static ProtectionSnapshot EvaluateFor(
+ ProtectionEngine engine,
+ DateTimeOffset start,
+ TimeSpan duration,
+ double phaseA,
+ double phaseB,
+ double phaseC,
+ double residual,
+ bool allowsTrip,
+ string healthReason = "SMV HEALTHY")
+ {
+ var snapshot = engine.Evaluate(new MeasurementFrame(
+ start,
+ phaseA,
+ phaseB,
+ phaseC,
+ residual,
+ allowsTrip,
+ healthReason));
+
+ const int stepMilliseconds = 20;
+ for (var elapsed = stepMilliseconds; elapsed <= duration.TotalMilliseconds; elapsed += stepMilliseconds)
+ {
+ snapshot = engine.Evaluate(new MeasurementFrame(
+ start.AddMilliseconds(elapsed),
+ phaseA,
+ phaseB,
+ phaseC,
+ residual,
+ allowsTrip,
+ healthReason));
+ }
+
+ return snapshot;
+ }
+}