From a72881a1f208dbcda8eedfb089b4bd084c0bb0ce Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:28:53 +0700 Subject: [PATCH 01/16] feat(virtual-relay): scaffold premium WPF laboratory app --- .../AR.Iec61850.VirtualRelayLab.csproj | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/AR.Iec61850.VirtualRelayLab.csproj 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. + + + From 2d13175674fe52ef51ba547580840bbb9871dbfb Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:29:25 +0700 Subject: [PATCH 02/16] feat(virtual-relay): add lean industrial design system --- apps/AR.Iec61850.VirtualRelayLab/App.xaml | 124 ++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/App.xaml diff --git a/apps/AR.Iec61850.VirtualRelayLab/App.xaml b/apps/AR.Iec61850.VirtualRelayLab/App.xaml new file mode 100644 index 0000000..f9f71a4 --- /dev/null +++ b/apps/AR.Iec61850.VirtualRelayLab/App.xaml @@ -0,0 +1,124 @@ + + + #F2F5F8 + #FFFFFF + #F8FAFC + #D8E0E8 + #17212B + #667789 + #2F6E9C + #E8F1F7 + #4C9B58 + #C28A2C + #C84B48 + #AEBBC5 + #8E9EAA + #EAF0E9 + #50637A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e671df9ab589d39ac0d93b1f93d6924c88aad530 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:29:34 +0700 Subject: [PATCH 03/16] feat(virtual-relay): add WPF application entry point --- apps/AR.Iec61850.VirtualRelayLab/App.xaml.cs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/App.xaml.cs 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 +{ +} From 3ba5670a1503eedc876213d6f6092c3e04be3947 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:30:21 +0700 Subject: [PATCH 04/16] feat(virtual-relay): add two-cycle SMV waveform scope --- .../Controls/WaveformScope.cs | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/Controls/WaveformScope.cs 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); + } +} From e6bf7fc22f7f092cbff6da3e8a48a1db4c346b9b Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:30:52 +0700 Subject: [PATCH 05/16] feat(virtual-relay): add deterministic 50/51 and earth-fault core --- .../Protection/ProtectionEngine.cs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/Protection/ProtectionEngine.cs 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; + } +} From 158e7bbaf3c2b3805c91a38264e06cd88334f9c4 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 10:32:56 +0700 Subject: [PATCH 06/16] feat(virtual-relay): build premium one-screen laboratory workspace --- .../MainWindow.xaml | 450 ++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 apps/AR.Iec61850.VirtualRelayLab/MainWindow.xaml 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +