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 6299739c3..d9ed9af87 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -31,13 +31,6 @@ private sealed class RuntimePointState public bool ReportChangeVerified { get; set; } public DateTime LastReportUtc { get; set; } = DateTime.MinValue; public bool ReportMissLogged { get; set; } - public string CommandFeedbackValue { get; set; } = string.Empty; - public DateTime LastCommandFeedbackUtc { get; set; } = DateTime.MinValue; - public DateTime CommandFeedbackGuardUntilUtc { get; set; } = DateTime.MinValue; - public DateTime CommandReportDeadlineUtc { get; set; } = DateTime.MinValue; - public bool AwaitingCommandReportEdge { get; set; } - public bool CommandReportMissLogged { get; set; } - public bool StaleReportSuppressedLogged { get; set; } public bool ReportValueRejectedLogged { get; set; } public string LastLoggedDegradedQuality { get; set; } = string.Empty; public int ConsecutiveErrors { get; set; } @@ -609,8 +602,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" @@ -684,50 +679,6 @@ public async Task ExecuteControlAsync( return result; } - private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefinition signal, string feedbackValue) - { - var references = BuildControlFeedbackReferences(signal); - var point = FindPointForControlFeedback(session, references); - if (point == null || !session.States.TryGetValue(point.PointKey, out var state)) - { - Log("WARN", session.Device.Name, - $"Control feedback {feedbackValue} was confirmed by the IED, but no monitored status point matched {string.Join(", ", references)}. Live Monitor was not updated directly."); - return; - } - - var nowUtc = DateTime.UtcNow; - var display = Iec61850ValueFormatter.Format(feedbackValue, point.IecDataType, point.Unit); - state.CommandFeedbackValue = display; - state.LastCommandFeedbackUtc = nowUtc; - state.CommandFeedbackGuardUntilUtc = nowUtc.AddSeconds(2); - state.CommandReportDeadlineUtc = nowUtc.AddSeconds(2); - state.AwaitingCommandReportEdge = session.PointPlanIds.ContainsKey(point.PointKey); - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - if (state.AwaitingCommandReportEdge) - state.ReportChangeVerified = false; - - var sourceMode = string.IsNullOrWhiteSpace(state.AcquisitionLabel) - ? "Control feedback" - : state.AcquisitionLabel; - ApplyValueUpdate( - session, - point, - display, - state.Quality, - state.DeviceTimestamp, - sourceMode, - "confirmed command feedback / awaiting matching dchg", - nowUtc, - state.AwaitingCommandReportEdge - ? "Live / command feedback immediate, awaiting report edge" - : "Live / control feedback confirmed", - trustReportEdge: false); - - Log("INFO", session.Device.Name, - $"Live Monitor feedback injected immediately: {point.IecReference}={display}; reportCorrelation={(state.AwaitingCommandReportEdge ? "awaiting dchg" : "not report-assigned")}."); - } - public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) { ArgumentException.ThrowIfNullOrWhiteSpace(deviceId); @@ -1231,45 +1182,14 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT state.ReportValueRejectedLogged = false; var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime; - var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc); - var commandValueMatches = update.HasValue && state.AwaitingCommandReportEdge && - AreSemanticallyEquivalent(point, state.CommandFeedbackValue, display); - var contradictsCommandFeedback = update.HasValue && state.AwaitingCommandReportEdge && - !commandValueMatches; - var provablyOlderThanCommand = contradictsCommandFeedback && hasSourceTimestamp && - reportSourceUtc < state.LastCommandFeedbackUtc.AddMilliseconds(-5); - var unlabelledSnapshotInsideGuard = contradictsCommandFeedback && !hasSourceTimestamp && - receivedUtc <= state.CommandFeedbackGuardUntilUtc && - !IsChangeReportReason(update.Reason); - if (provablyOlderThanCommand || unlabelledSnapshotInsideGuard) - { - state.ReportTrafficSeen = true; - state.LastReportUtc = DateTime.UtcNow; - if (!state.StaleReportSuppressedLogged) - { - state.StaleReportSuppressedLogged = true; - Log("WARN", session.Device.Name, - $"{point.SignalName}: stale report value {display} was suppressed after command-confirmed {state.CommandFeedbackValue}; reportTime={(hasSourceTimestamp ? reportSourceUtc.ToString("O", CultureInfo.InvariantCulture) : "not supplied")}."); - } - continue; - } - var hadValueBeforeReport = state.HasValue; var valueChangedByReport = update.HasValue && hadValueBeforeReport && HasExactSemanticEdge(point, state.Value, display); state.ReportTrafficSeen = true; state.LastReportUtc = DateTime.UtcNow; - if (commandValueMatches && - (!hasSourceTimestamp || reportSourceUtc >= state.LastCommandFeedbackUtc.AddMilliseconds(-5))) - { - state.AwaitingCommandReportEdge = false; - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - state.ReportChangeVerified = true; - state.ReportMissLogged = false; - Log("INFO", session.Device.Name, - $"{point.SignalName}: event-driven report confirmed command feedback {display}; reason={update.Reason}; reportTime={(hasSourceTimestamp ? reportSourceUtc.ToString("O", CultureInfo.InvariantCulture) : "not supplied")}."); - } - else if (valueChangedByReport || IsChangeReportReason(update.Reason)) + // IEC 61850 process truth comes from independent acquisition, never from + // a prior control result. A real IED report is therefore never suppressed, + // rewritten, or correlated against command-side feedback before publication. + if (valueChangedByReport || IsChangeReportReason(update.Reason)) { state.ReportChangeVerified = true; state.ReportMissLogged = false; @@ -1540,15 +1460,6 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c var normalizedQuality = NormalizeQuality(quality); var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp; - if (reportAssigned && state.AwaitingCommandReportEdge && - nowUtc >= state.CommandReportDeadlineUtc && !state.CommandReportMissLogged) - { - state.CommandReportMissLogged = true; - state.ReportChangeVerified = false; - Log("WARN", session.Device.Name, - $"{point.SignalName}: command feedback reached {state.CommandFeedbackValue}, but no matching dchg report arrived within 2 seconds. MMS validation remains active until event delivery is proven."); - } - var pollDetectedChange = state.HasValue && !string.Equals(state.Value, display, StringComparison.Ordinal); var sourceMode = "MMS polling"; var reason = "cyclic"; @@ -1986,13 +1897,6 @@ private static void ResetAssociationReportEvidence(DeviceSession session) state.ReportChangeVerified = false; state.LastReportUtc = DateTime.MinValue; state.ReportMissLogged = false; - state.AwaitingCommandReportEdge = false; - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - state.CommandFeedbackValue = string.Empty; - state.LastCommandFeedbackUtc = DateTime.MinValue; - state.CommandFeedbackGuardUntilUtc = DateTime.MinValue; - state.CommandReportDeadlineUtc = DateTime.MinValue; state.AcquisitionLabel = session.StaticDataSetReportOnly ? "Static DataSet report rearming" : "MMS polling"; state.SourceMode = session.StaticDataSetReportOnly ? "Static DataSet report rearming" @@ -2528,71 +2432,6 @@ private static void IndexPointReference(DeviceSession session, Iec61850MonitorPo session.ReportReferenceIndex.TryAdd(key, point); } - private static IReadOnlyList BuildControlFeedbackReferences(SignalDefinition signal) - { - var references = new List(3); - void Add(string? reference) - { - if (string.IsNullOrWhiteSpace(reference)) - return; - var trimmed = reference.Trim(); - if (!references.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) - references.Add(trimmed); - } - - Add(signal.ControlStatusReference); - Add(signal.ObjectReference); - if (!string.IsNullOrWhiteSpace(signal.ObjectReference) && - !NormalizeReference(signal.ObjectReference).EndsWith(".stval", StringComparison.OrdinalIgnoreCase)) - { - Add(signal.ObjectReference.TrimEnd('.') + ".stVal"); - } - return references; - } - - private static Iec61850MonitorPoint? FindPointForControlFeedback( - DeviceSession session, - IReadOnlyList references) - { - foreach (var reference in references) - { - var exact = FindPointForReportReference(session, reference); - if (exact != null) - return exact; - } - - // Some IEDs expose control objects with the IED name prepended to the MMS - // domain (for example OLSF501CB1) while the live point uses CB1. Match the - // member path plus a unique domain suffix, never the member path alone when - // more than one monitored logical device could match. - foreach (var reference in references) - { - var source = CanonicalDataReference(reference); - var sourceSlash = source.IndexOf('/'); - if (sourceSlash <= 0 || sourceSlash >= source.Length - 1) - continue; - var sourceDomain = source[..sourceSlash]; - var sourceMember = source[(sourceSlash + 1)..]; - var candidates = session.Points.Values.Where(candidate => - { - var target = CanonicalDataReference(candidate.IecReference); - var targetSlash = target.IndexOf('/'); - if (targetSlash <= 0 || targetSlash >= target.Length - 1) - return false; - var targetDomain = target[..targetSlash]; - var targetMember = target[(targetSlash + 1)..]; - return sourceMember.Equals(targetMember, StringComparison.OrdinalIgnoreCase) && - (sourceDomain.EndsWith(targetDomain, StringComparison.OrdinalIgnoreCase) || - targetDomain.EndsWith(sourceDomain, StringComparison.OrdinalIgnoreCase)); - }).Distinct().ToArray(); - - if (candidates.Length == 1) - return candidates[0]; - } - - return null; - } - private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference) { foreach (var key in GetReferenceKeys(reference)) 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/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md index 73c8bc511..38e9ee086 100644 --- a/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md +++ b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md @@ -1,16 +1,21 @@ # Trusted SCL golden-wire physical trial -This document freezes the first field-test contract for the protocol-only ARSAS 1.6.36 trial lane. +This document freezes the physical qualification contract for the combined ARSAS 1.6.36 candidate after integrating the field-proven process-feedback/analyzer workstream with ARIEC reporting/bootstrap convergence. -## Immutable engine authority +## Immutable candidate authority -- ARSAS branch: `trial/scl-golden-wire-v1636` -- ARIEC61850 engine: `e41def0a2676efb8a143905798155f6bccc6f047` -- Field-proven reporting/control baseline preserved by the engine lock: `11ab2304482600c19ba979f4fc9021ddb46b9af9` +- Combined ARSAS base: PR `#312` exact head `cd1e172ff59160d7b7b1875008b515817fe180ab` +- Qualification branch: `integration/v1636-312-ariec-convergence-0023ef9` +- ARIEC61850 convergence engine: `0023ef9a4373855497464ed3979e359c4041c95d` +- ARIEC source PR: `#132` +- ARIEC exact-head .NET CI: `#603` PASS +- Field-proven reporting/control baseline retained by the engine lock: `11ab2304482600c19ba979f4fc9021ddb46b9af9` + +The engine lock and exact combined ARSAS commit are the build-time authorities. Do not substitute another ARIEC checkout or an older ARSAS artifact while collecting qualification evidence. ## Gate 1 — read-only safe trial -Run the portable application with Wireshark capturing TCP port 102. +Run the exact portable candidate with Wireshark capturing TCP port 102. ```powershell ARSAS-1.6.36-win-x64-portable.exe --scl-safe-trial "C:\path\IED.cid" "IEDNAME" "AP1" "192.168.x.x" 102 @@ -32,13 +37,53 @@ Only after Gate 1 association/read behavior is understood, open the same verifie Trusted-SCL Play verifies the imported source SHA-256 before socket activity, keeps the SCL IED/AccessPoint association identity, performs Domain/VMD validation and bounded initial Reads, and does not silently fall back to cached association or full discovery. -For Static DataSet report-only mode, ordered DataSet membership and RCB identity remain SCL-authoritative in memory. The trusted path does not perform a network DataSet-directory browse or create/delete a dynamic DataSet. +For Static DataSet report-only mode, ordered DataSet membership and RCB identity remain SCL-authoritative in memory. The trusted path does not perform a network DataSet-directory browse and does not create/delete a dynamic DataSet. + +The InformationReport receiver must be registered before any report-control write. The expected startup sequence is: + +- BRCB: whole-RCB Read -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read -> one explicit `GI=true`. +- URCB: whole-RCB Read -> `Resv=true` when exposed -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read -> one explicit `GI=true`. +- BRCB `ResvTms` is retry-only after a real direct-`RptEna` rejection; it is not the primary startup path. +- `GI=true` is a one-shot startup bootstrap only and is sent only after report routing is registered and activation/readback succeeds. +- GI rejection is a startup failure: unregister the monitor, disable `RptEna`, release any reservation touched by this client, and report failure instead of presenting an active monitor with unknown initial values. +- No network DataSet-directory browse, dynamic DataSet mutation, cyclic GI, or cyclic MMS process polling is allowed on the trusted-SCL report path. + +For the AA1E1F06R4 qualification target used by the golden comparison, expected evidence is: + +- BRCB family `Buffer`: a concrete live indexed instance is enabled without pre-reserving it; startup GI is accepted; Digital report data arrives. +- URCB family `Unbuffer`: a concrete live indexed instance is reserved when `Resv` is exposed, enabled, startup GI is accepted; Analog report data arrives. +- All 58 selected static DataSet members receive an initial value without waiting for a process change. +- Structured members such as total power factor remain schema/semantic projected rather than silently falling back to an unrelated scalar. + +## Gate 3 — preserve the PR #312 process-state boundary + +The combined candidate must also retain the field-accepted behavior from PR #312: + +- successful Open/Close control must not inject command feedback into monitored process `stVal`; +- Live Monitor process state changes only from real IEC 61850 acquisition/report authority; +- the control dialog must not overwrite Current Value from command result alone; +- phasor and harmonic easing remains presentation-only and must not change engineering values or report authority. + +## Gate 4 — steady state and cleanup + +After the startup initial image: + +- values must continue from InformationReport traffic/event updates; +- no periodic MMS process polling or repeated GI may be introduced; +- buffered backlog is applied in receive order so the canonical current-state plane retains the latest supplied value per signal while quality/timestamp-only updates do not erase the previous primary value; +- Stop/Close must disable every report enabled by this client; +- URCB reservation must be released when this client touched it; +- BRCB reservation must be released only when the compatibility fallback actually touched it; +- association disposal must happen after best-effort report cleanup, not instead of cleanup. + +## Evidence required for PASS -Primary activation expectation: +Physical success is not claimed by CI alone. Preserve the exact candidate SHA/artifact identity and collect: -- BRCB: whole-RCB Read -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read. -- URCB: whole-RCB Read -> `Resv=true` when exposed -> `RptEna=true` -> two whole-RCB readbacks. -- BRCB `ResvTms` is retry-only after a real direct-`RptEna` rejection. -- GI is not sent implicitly. +1. ARSAS Diagnostic Export covering trusted-SCL association, RCB selection/activation, explicit startup GI, InformationReport reception and cleanup. +2. Matching Wireshark PCAP/PCAPNG for TCP port 102. +3. Screenshot or exported monitor evidence showing complete initial state and later event-driven updates. +4. Stop/Close evidence showing deterministic RCB release. +5. One control operation showing that monitored process state follows independent IED feedback rather than synthetic command feedback. -Physical success is not claimed by CI. JSON evidence plus Wireshark capture from the real IED are the acceptance evidence. +A PASS requires the software gates and the physical evidence to agree. If the wire capture contradicts UI/status text, the wire evidence is authoritative and the candidate remains blocked. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 07268d069..bc0491d2e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,14 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", - "sourcePullRequest": 125, - "purpose": "Canonical protocol-only golden-wire SCL-assisted physical-trial pin. This exact SHA is built from the immutable ARSAS field-proven engine baseline recorded below and passed the dedicated convergence CI. It adds SCL-derived association identity, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, trusted static-report activation without hidden full discovery or network DataSet-directory browsing, accepts quoted Edition-1/vendor OSI-AP-Title lexical forms such as \"1,1,1,999,1\" while preserving the raw SCL parameter for diagnostics, and preserves scoped DataSet/RCB authority including ReportControl indexed semantics for trusted-SCL live monitoring. For normal Play, ARSAS explicitly requests one startup GI only after the InformationReport receiver is registered, RCB activation succeeds, and two whole-RCB readbacks complete; BRCB direct RptEna remains primary with ResvTms retry-only, while URCB Resv precedes RptEna when exposed. Explicit GI acceptance is a startup success gate: rejection unregisters the monitor, disables RptEna, releases any touched reservation, and returns failure instead of a misleading active monitor with Unknown initial values. Safe-trial keeps GI disabled and performs no RCB writes. PR #125 is an evidence/trial lane and is not a merge authority for ARIEC main; ARSAS checks out this immutable SHA directly.", + "commit": "0023ef9a4373855497464ed3979e359c4041c95d", + "sourcePullRequest": 132, + "purpose": "Temporary ARSAS 1.6.36 combined-workstream qualification pin for the exact green ARIEC convergence head. ARIEC .NET CI #603 passed on this SHA, including provenance/source/license verification, restore, build, tests, and diagnostics. The convergence preserves the trusted-SCL golden-wire contracts used by ARSAS: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. The same convergence also locks buffered BRCB latest-state semantics through the canonical runtime value plane and preserves SCL RptEnabled@max only as diagnostics metadata; it is never authority to synthesize concrete runtime RCB names.", + "previousTrialPin": { + "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", + "sourcePullRequest": 125, + "purpose": "Previous ARSAS 1.6.36 trusted-SCL golden-wire trial pin retained for explicit ancestry." + }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index ab82b5bfe..9351dab79 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -4,6 +4,26 @@ 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("ApplyControlFeedbackToMonitor(", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackValue", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackGuardUntilUtc", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("AwaitingCommandReportEdge", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("BuildControlFeedbackReferences", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("FindPointForControlFeedback", runtimeSource, StringComparison.Ordinal); + Assert.Contains("A real IED report is therefore never suppressed", 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/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs index 30e3e304e..fcc73011e 100644 --- a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs +++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs @@ -197,6 +197,8 @@ public void ConcurrentMonitorUpdatesAndSnapshots_RemainAtomicAndConsistent() var planning = new NativeHybridReportPlanningResult { IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = "FullReportCoverage", ReportPlans = [plan], StaticUrcbSignalCount = 1, Warnings = ["stable warning"] @@ -272,7 +274,7 @@ public void Reconnect_RecoversMmsFirst_AndDefersReportRearmToBackgroundPipeline( var reset = source[resetStart..resetEnd]; Assert.Contains("state.ReportTrafficSeen = false;", reset, StringComparison.Ordinal); Assert.Contains("state.ReportChangeVerified = false;", reset, StringComparison.Ordinal); - Assert.Contains("state.AwaitingCommandReportEdge = false;", reset, StringComparison.Ordinal); + Assert.DoesNotContain("AwaitingCommandReportEdge", reset, StringComparison.Ordinal); Assert.Contains("session.StaticDataSetReportOnly ? \"Static DataSet report rearming\" : \"MMS polling\"", reset, StringComparison.Ordinal); Assert.Contains("\"Report rearming / MMS polling fallback\"", reset, StringComparison.Ordinal); Assert.Contains("\"new MMS association / configured RCB evidence reset; MMS process fallback disabled\"", reset, StringComparison.Ordinal); 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); + } +}