Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions ControlCommandWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)));
Expand Down
88 changes: 87 additions & 1 deletion Controls/ComtradeHarmonicsWorkstationView.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Globalization;
using System.Windows;
using System.Windows.Input;
Expand Down Expand Up @@ -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<ComtradeHarmonicOverviewSpectrum> _spectra = Array.Empty<ComtradeHarmonicOverviewSpectrum>();
private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty<ComtradeHarmonicOverviewSpectrum>();
private long _lastPresentationTimestamp;
private PreparedSpectrumRow[] _preparedRows = Array.Empty<PreparedSpectrumRow>();
private int _maximumDisplayedOrder = -1;
private string _title = "Harmonics";
Expand Down Expand Up @@ -105,7 +109,14 @@ internal void ShowSpectra(
{
_title = title ?? string.Empty;
_subtitle = subtitle ?? string.Empty;
_spectra = spectra ?? Array.Empty<ComtradeHarmonicOverviewSpectrum>();
var targetSpectra = spectra ?? Array.Empty<ComtradeHarmonicOverviewSpectrum>();
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);
Expand All @@ -118,6 +129,8 @@ internal void ShowMessage(string title, string message)
_title = title ?? string.Empty;
_subtitle = message ?? string.Empty;
_spectra = Array.Empty<ComtradeHarmonicOverviewSpectrum>();
_smoothedSpectra = Array.Empty<ComtradeHarmonicOverviewSpectrum>();
_lastPresentationTimestamp = 0;
_preparedRows = Array.Empty<PreparedSpectrumRow>();
_maximumDisplayedOrder = -1;
_rowTargets.Clear();
Expand Down Expand Up @@ -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<ComtradeHarmonicOverviewSpectrum> previous,
IReadOnlyList<ComtradeHarmonicOverviewSpectrum> target,
double elapsedMilliseconds)
{
if (target.Count == 0)
return Array.Empty<ComtradeHarmonicOverviewSpectrum>();

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<ComtradeHarmonicOverviewSpectrum> spectra,
int maximumOrder)
Expand Down
67 changes: 65 additions & 2 deletions Controls/ComtradePhasorView.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using ArIED61850Tester.Services;

namespace ArIED61850Tester.Controls;

Expand Down Expand Up @@ -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<ComtradePhasorVector>();
private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty<ComtradePhasorVector>();
private long _lastPresentationTimestamp;

internal void ShowPhasors(
string referenceLabel,
Expand All @@ -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);
Comment on lines +63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Commit the exact final analyzer result

When a fast scrub ends, the final request calls ShowPhasors only once and the composition pump then stops, so these eased arrays remain between the previous and requested frame indefinitely; no render callback advances them to the target. The phasor labels/vectors therefore report values from neither frame after the user releases the cursor, and ComtradeHarmonicsWorkstationView.ShowSpectra has the same issue. Bypass easing for the final request or continue animation until committing the exact target.

AGENTS.md reference: AGENTS.md:L103-L106

Useful? React with 👍 / 👎.

_voltagePanel = PreparePanel(_smoothedVoltageVectors);
_currentPanel = PreparePanel(_smoothedCurrentVectors);
_message = string.Empty;
InvalidateVisual();
}
Expand All @@ -60,6 +75,9 @@ internal void ShowMessage(string title, string message)
_referenceDetail = message ?? string.Empty;
_voltagePanel = PreparedPhasorPanel.Empty;
_currentPanel = PreparedPhasorPanel.Empty;
_smoothedVoltageVectors = Array.Empty<ComtradePhasorVector>();
_smoothedCurrentVectors = Array.Empty<ComtradePhasorVector>();
_lastPresentationTimestamp = 0;
_message = message ?? string.Empty;
InvalidateVisual();
}
Expand Down Expand Up @@ -111,6 +129,51 @@ protected override void OnRender(DrawingContext dc)
}
}

private static ComtradePhasorVector[] SmoothVectors(
IReadOnlyList<ComtradePhasorVector> previous,
IReadOnlyList<ComtradePhasorVector>? target,
double elapsedMilliseconds)
{
if (target is null || target.Count == 0)
return Array.Empty<ComtradePhasorVector>();

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<ComtradePhasorVector>? source)
{
if (source is null || source.Count == 0)
Expand Down
6 changes: 4 additions & 2 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,10 @@ public async Task<Iec61850ControlCommandResult> 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"
Expand Down
56 changes: 56 additions & 0 deletions Services/PresentationEasingMath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace ArIED61850Tester.Services;

/// <summary>
/// Allocation-free presentation easing helpers. These functions are intentionally presentation-only:
/// raw COMTRADE/IEC 61850 engineering values remain untouched and authoritative.
/// </summary>
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid targets instead of retaining stale values

When a later COMTRADE result contains a non-finite magnitude but has the same channel topology, returning current converts that invalid target into the previous finite value. Consequently, the existing finite-value filtering in PreparePanel cannot remove the vector, and the UI displays a stale magnitude under the new cursor/reference rather than indicating that the new result is invalid; non-finite targets must remain invalid or cause that vector to be dropped.

AGENTS.md reference: AGENTS.md:L60-L66

Useful? React with 👍 / 👎.

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));
}
}
13 changes: 13 additions & 0 deletions tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
31 changes: 31 additions & 0 deletions tests/ARSAS.Tests/PresentationEasingMathTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading