diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 23d9014ee..d9769afa5 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -187,10 +187,12 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e) }, _cancellation.Token); - CommandStage = result.Stage; + CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage; CommandStatus = BuildCommandResultText(result); - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - CurrentValue = result.FeedbackValue; + if (result.IsSuccess && !TestMode) + { + CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path."; + } SetResultTone(result.IsSuccess ? "Success" : "Error"); } catch (OperationCanceledException) @@ -312,7 +314,7 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-") details.Add($"Control service: {result.ElapsedText}."); if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") - details.Add($"Process feedback: {result.FeedbackElapsedText}."); + details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal."); if (!string.IsNullOrWhiteSpace(result.TotalElapsedText) && result.TotalElapsedText != "-") details.Add($"Total: {result.TotalElapsedText}."); return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text))); diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index 17fe7bfa4..73580609a 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Input; @@ -65,7 +66,10 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private static readonly Pen FooterDividerPen = FreezePen(Color.FromRgb(233, 237, 243), 1); private static readonly string[] OrderLabels = CreateOrderLabels(); + private const double PresentationTimeConstantMs = 92.0; private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private long _lastPresentationTimestamp; private PreparedSpectrumRow[] _preparedRows = Array.Empty(); private int _maximumDisplayedOrder = -1; private string _title = "Harmonics"; @@ -105,7 +109,14 @@ internal void ShowSpectra( { _title = title ?? string.Empty; _subtitle = subtitle ?? string.Empty; - _spectra = spectra ?? Array.Empty(); + var targetSpectra = spectra ?? Array.Empty(); + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? double.PositiveInfinity + : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; + _lastPresentationTimestamp = now; + _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds); + _spectra = _smoothedSpectra; _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); @@ -118,6 +129,8 @@ internal void ShowMessage(string title, string message) _title = title ?? string.Empty; _subtitle = message ?? string.Empty; _spectra = Array.Empty(); + _smoothedSpectra = Array.Empty(); + _lastPresentationTimestamp = 0; _preparedRows = Array.Empty(); _maximumDisplayedOrder = -1; _rowTargets.Clear(); @@ -296,6 +309,79 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) FooterRateBrush, new Point(bounds.Right - 16, y), dpi); } + private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( + IReadOnlyList previous, + IReadOnlyList target, + double elapsedMilliseconds) + { + if (target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) + { + var before = previous[spectrumIndex]; + var next = target[spectrumIndex]; + if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) || + !string.Equals(before.Units, next.Units, StringComparison.Ordinal) || + before.Bins.Count != next.Bins.Count) + { + topologyMatches = false; + break; + } + for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++) + { + if (before.Bins[binIndex].Order != next.Bins[binIndex].Order) + { + topologyMatches = false; + break; + } + } + if (!topologyMatches) break; + } + } + + var result = new ComtradeHarmonicOverviewSpectrum[target.Count]; + for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) + { + var next = target[spectrumIndex]; + if (!topologyMatches) + { + result[spectrumIndex] = next with { Bins = next.Bins.ToArray() }; + continue; // First sample/channel-set change snaps; never invent a ramp from zero. + } + + var before = previous[spectrumIndex]; + var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count]; + for (var binIndex = 0; binIndex < bins.Length; binIndex++) + { + var previousBin = before.Bins[binIndex]; + var targetBin = next.Bins[binIndex]; + bins[binIndex] = new ComtradeHarmonicDisplayBin( + targetBin.Order, + PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); + } + + result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum( + next.SignalName, + next.Units, + PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs), + next.DominantOrder, + PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs), + next.EstimatedSampleRateHz, + next.MaximumResolvableOrder, + bins); + } + return result; + } + private static PreparedSpectrumRow[] PrepareRows( IReadOnlyList spectra, int maximumOrder) diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 369d105dd..9564c3081 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Media; +using ArIED61850Tester.Services; namespace ArIED61850Tester.Controls; @@ -34,9 +36,13 @@ public sealed class ComtradePhasorView : FrameworkElement private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty; private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty; + private const double PresentationTimeConstantMs = 78.0; private string _headerLabel = "Fundamental phasors at C1"; private string _referenceDetail = "Select a valid analysis reference"; private string _message = string.Empty; + private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty(); + private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty(); + private long _lastPresentationTimestamp; internal void ShowPhasors( string referenceLabel, @@ -47,8 +53,17 @@ internal void ShowPhasors( var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; _headerLabel = $"Fundamental phasors at {resolvedReference}"; _referenceDetail = referenceDetail ?? string.Empty; - _voltagePanel = PreparePanel(voltageVectors); - _currentPanel = PreparePanel(currentVectors); + + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? double.PositiveInfinity + : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; + _lastPresentationTimestamp = now; + + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds); + _voltagePanel = PreparePanel(_smoothedVoltageVectors); + _currentPanel = PreparePanel(_smoothedCurrentVectors); _message = string.Empty; InvalidateVisual(); } @@ -60,6 +75,9 @@ internal void ShowMessage(string title, string message) _referenceDetail = message ?? string.Empty; _voltagePanel = PreparedPhasorPanel.Empty; _currentPanel = PreparedPhasorPanel.Empty; + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _lastPresentationTimestamp = 0; _message = message ?? string.Empty; InvalidateVisual(); } @@ -111,6 +129,51 @@ protected override void OnRender(DrawingContext dc) } } + private static ComtradePhasorVector[] SmoothVectors( + IReadOnlyList previous, + IReadOnlyList? target, + double elapsedMilliseconds) + { + if (target is null || target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + for (var index = 0; index < target.Count; index++) + { + if (!string.Equals(previous[index].Label, target[index].Label, StringComparison.Ordinal) || + !string.Equals(previous[index].Phase, target[index].Phase, StringComparison.Ordinal) || + !string.Equals(previous[index].Units, target[index].Units, StringComparison.Ordinal)) + { + topologyMatches = false; + break; + } + } + } + + var output = new ComtradePhasorVector[target.Count]; + if (!topologyMatches) + { + for (var index = 0; index < target.Count; index++) + output[index] = target[index]; + return output; // First sample/topology change snaps: no artificial ramp from zero. + } + + for (var index = 0; index < target.Count; index++) + { + var before = previous[index]; + var next = target[index]; + output[index] = new ComtradePhasorVector( + next.Label, + next.Phase, + next.Units, + PresentationEasingMath.Smooth(before.MagnitudeRms, next.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees(before.AngleDegrees, next.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); + } + return output; + } + private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source) { if (source is null || source.Count == 0) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0b939ec05..49c6eb846 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -536,8 +536,10 @@ public async Task ExecuteControlAsync( if (result.ServiceAccepted || result.FeedbackConfirmed || result.IsSuccess) RecordSuccessfulIo(session); - if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue); + // IMPORTANT: a successful/confirmed IEC 61850 control service is command-path evidence, + // not process-image authority. Never synthesize or inject stVal from Operate/SBO feedback here. + // The monitored state changes only through the independent acquisition path (RCB/report or + // an explicit authoritative MMS read performed by the monitor), matching IED engineering tools. var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase) ? "NOT SENT TO IED" diff --git a/Services/PresentationEasingMath.cs b/Services/PresentationEasingMath.cs new file mode 100644 index 000000000..3930e6856 --- /dev/null +++ b/Services/PresentationEasingMath.cs @@ -0,0 +1,56 @@ +namespace ArIED61850Tester.Services; + +/// +/// Allocation-free presentation easing helpers. These functions are intentionally presentation-only: +/// raw COMTRADE/IEC 61850 engineering values remain untouched and authoritative. +/// +public static class PresentationEasingMath +{ + public static double ExponentialAlpha(double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(timeConstantMilliseconds) || timeConstantMilliseconds <= 0.0) + return 1.0; + if (double.IsPositiveInfinity(elapsedMilliseconds)) + return 1.0; + if (!double.IsFinite(elapsedMilliseconds) || elapsedMilliseconds <= 0.0) + return 0.0; + + var alpha = 1.0 - Math.Exp(-elapsedMilliseconds / timeConstantMilliseconds); + return Math.Clamp(alpha, 0.0, 1.0); + } + + public static double Smooth(double current, double target, double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(target)) return current; + if (!double.IsFinite(current)) return target; + var alpha = ExponentialAlpha(elapsedMilliseconds, timeConstantMilliseconds); + return current + ((target - current) * alpha); + } + + public static double ShortestAngleDeltaDegrees(double currentDegrees, double targetDegrees) + { + if (!double.IsFinite(currentDegrees) || !double.IsFinite(targetDegrees)) + return 0.0; + var delta = (targetDegrees - currentDegrees) % 360.0; + if (delta >= 180.0) delta -= 360.0; + if (delta < -180.0) delta += 360.0; + return delta; + } + + public static double NormalizeAngleDegrees(double degrees) + { + if (!double.IsFinite(degrees)) return 0.0; + var normalized = degrees % 360.0; + if (normalized >= 180.0) normalized -= 360.0; + if (normalized < -180.0) normalized += 360.0; + return normalized; + } + + public static double SmoothAngleDegrees(double currentDegrees, double targetDegrees, double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(targetDegrees)) return NormalizeAngleDegrees(currentDegrees); + if (!double.IsFinite(currentDegrees)) return NormalizeAngleDegrees(targetDegrees); + var alpha = ExponentialAlpha(elapsedMilliseconds, timeConstantMilliseconds); + return NormalizeAngleDegrees(currentDegrees + (ShortestAngleDeltaDegrees(currentDegrees, targetDegrees) * alpha)); + } +} diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index a8662812b..982fb8f65 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -4,6 +4,19 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { + [Fact] + public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() + { + var root = RepoRoot(); + var runtimeSource = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs")); + var commandWindowSource = File.ReadAllText(Path.Combine(root, "ControlCommandWindow.xaml.cs")); + + Assert.DoesNotContain("ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CurrentValue = result.FeedbackValue", commandWindowSource, StringComparison.Ordinal); + Assert.Contains("Waiting for independent IED process feedback", commandWindowSource, StringComparison.Ordinal); + Assert.Contains("not process-image authority", runtimeSource, StringComparison.Ordinal); + } + [Fact] public void EngineLock_PreservesExactG1FieldProvenAncestryAcrossReviewedPinAdvances() { diff --git a/tests/ARSAS.Tests/PresentationEasingMathTests.cs b/tests/ARSAS.Tests/PresentationEasingMathTests.cs new file mode 100644 index 000000000..7622fd189 --- /dev/null +++ b/tests/ARSAS.Tests/PresentationEasingMathTests.cs @@ -0,0 +1,31 @@ +using ArIED61850Tester.Services; +using Xunit; + +namespace ARSAS.Tests; + +public sealed class PresentationEasingMathTests +{ + [Fact] + public void ExponentialSmoothing_IsTimeBased_NotFrameCountBased() + { + const double tau = 80.0; + var halfStep = PresentationEasingMath.Smooth(0.0, 1.0, 16.67, tau); + var twoSteps = PresentationEasingMath.Smooth(halfStep, 1.0, 16.67, tau); + var oneStep = PresentationEasingMath.Smooth(0.0, 1.0, 33.34, tau); + Assert.Equal(oneStep, twoSteps, 12); + } + + [Fact] + public void AngleSmoothing_UsesShortestPathAcrossPlusMinus180() + { + Assert.Equal(2.0, PresentationEasingMath.ShortestAngleDeltaDegrees(179.0, -179.0), 10); + Assert.Equal(-2.0, PresentationEasingMath.ShortestAngleDeltaDegrees(-179.0, 179.0), 10); + } + + [Fact] + public void InfiniteElapsedTime_SnapsToTarget() + { + Assert.Equal(1.0, PresentationEasingMath.ExponentialAlpha(double.PositiveInfinity, 80.0)); + Assert.Equal(42.0, PresentationEasingMath.Smooth(10.0, 42.0, double.PositiveInfinity, 80.0), 10); + } +}