diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs new file mode 100644 index 00000000..1540a685 --- /dev/null +++ b/FaultRecordWindow.HeaderSelection.cs @@ -0,0 +1,196 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Adds a tri-state select-all checkbox to the fault-record Get column. Selecting all +/// affects only rows that are currently eligible for download; clearing always clears +/// every row so stale disabled selections cannot remain hidden. +/// +public partial class FaultRecordWindow +{ + private static readonly bool FaultRecordHeaderSelectionClassHandlerRegistered = + RegisterFaultRecordHeaderSelectionClassHandler(); + + private readonly HashSet _faultRecordHeaderObservedRows = new(); + private bool _faultRecordHeaderSelectionInstallScheduled; + private bool _faultRecordHeaderSelectionInstalled; + private bool _faultRecordHeaderBulkUpdate; + private CheckBox? _faultRecordHeaderSelectionCheckBox; + + private static bool RegisterFaultRecordHeaderSelectionClassHandler() + { + EventManager.RegisterClassHandler( + typeof(FaultRecordWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnFaultRecordHeaderSelectionWindowLoaded), + handledEventsToo: true); + return true; + } + + private static void OnFaultRecordHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FaultRecordWindow window || + window._faultRecordHeaderSelectionInstalled || + window._faultRecordHeaderSelectionInstallScheduled) + { + return; + } + + window._faultRecordHeaderSelectionInstallScheduled = true; + window.Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + window._faultRecordHeaderSelectionInstallScheduled = false; + window.EnsureFaultRecordHeaderSelection(); + })); + } + + private void EnsureFaultRecordHeaderSelection() + { + if (_faultRecordHeaderSelectionInstalled) + { + RefreshFaultRecordHeaderSelection(); + return; + } + + var column = FaultRecordsGrid.Columns.FirstOrDefault(candidate => + string.Equals(candidate.Header?.ToString(), "Get", StringComparison.OrdinalIgnoreCase)); + if (column == null) + return; + + var headerCheckBox = new CheckBox + { + Width = 16, + Height = 16, + IsThreeState = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = Cursors.Hand, + Focusable = true, + ToolTip = "Check / uncheck all downloadable fault records" + }; + AutomationProperties.SetName(headerCheckBox, "Toggle all downloadable fault records"); + headerCheckBox.Click += FaultRecordHeaderSelectionCheckBox_Click; + + column.Header = headerCheckBox; + _faultRecordHeaderSelectionCheckBox = headerCheckBox; + _faultRecordHeaderSelectionInstalled = true; + + PropertyChanged += FaultRecordHeaderSelectionWindow_PropertyChanged; + Records.CollectionChanged += FaultRecordHeaderSelectionRecords_CollectionChanged; + Closed += FaultRecordHeaderSelectionWindow_Closed; + RewireFaultRecordHeaderRows(); + RefreshFaultRecordHeaderSelection(); + } + + private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e) + { + if (IsBusy) + { + RefreshFaultRecordHeaderSelection(); + return; + } + + var target = GetFaultRecordHeaderSelectionState() != true; + _faultRecordHeaderBulkUpdate = true; + try + { + foreach (var row in Records) + { + if (target) + row.IsSelected = row.CanSelectForDownload; + else + row.IsSelected = false; + } + } + finally + { + _faultRecordHeaderBulkUpdate = false; + } + + RaiseSelectionState(); + RefreshFaultRecordHeaderSelection(); + } + + private void FaultRecordHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RefreshFaultRecordHeaderSelection(); + + private void FaultRecordHeaderSelectionRecords_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RewireFaultRecordHeaderRows(); + RefreshFaultRecordHeaderSelection(); + } + + private void RewireFaultRecordHeaderRows() + { + var current = Records.ToHashSet(); + foreach (var stale in _faultRecordHeaderObservedRows.Where(row => !current.Contains(row)).ToArray()) + { + stale.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged; + _faultRecordHeaderObservedRows.Remove(stale); + } + + foreach (var row in Records) + { + if (!_faultRecordHeaderObservedRows.Add(row)) + continue; + + row.PropertyChanged += FaultRecordHeaderSelectionRow_PropertyChanged; + } + } + + private void FaultRecordHeaderSelectionRow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_faultRecordHeaderBulkUpdate) + return; + + RefreshFaultRecordHeaderSelection(); + } + + private void RefreshFaultRecordHeaderSelection() + { + var header = _faultRecordHeaderSelectionCheckBox; + if (header == null) + return; + + var eligibleCount = Records.Count(row => row.CanSelectForDownload); + header.IsEnabled = !IsBusy && eligibleCount > 0; + header.IsChecked = GetFaultRecordHeaderSelectionState(); + } + + private bool? GetFaultRecordHeaderSelectionState() + { + var eligible = Records.Where(row => row.CanSelectForDownload).ToArray(); + if (eligible.Length == 0) + return false; + + var selected = eligible.Count(row => row.IsSelected); + if (selected == 0) + return false; + if (selected == eligible.Length) + return true; + return null; + } + + private void FaultRecordHeaderSelectionWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FaultRecordHeaderSelectionWindow_PropertyChanged; + Records.CollectionChanged -= FaultRecordHeaderSelectionRecords_CollectionChanged; + Closed -= FaultRecordHeaderSelectionWindow_Closed; + + foreach (var row in _faultRecordHeaderObservedRows) + row.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged; + _faultRecordHeaderObservedRows.Clear(); + + if (_faultRecordHeaderSelectionCheckBox != null) + _faultRecordHeaderSelectionCheckBox.Click -= FaultRecordHeaderSelectionCheckBox_Click; + } +} diff --git a/Iec61850TimestampPresentation.cs b/Iec61850TimestampPresentation.cs index e182f0ed..33e28dc2 100644 --- a/Iec61850TimestampPresentation.cs +++ b/Iec61850TimestampPresentation.cs @@ -41,6 +41,44 @@ public static string FormatMilliseconds(DateTimeOffset? value, string format, st public static string FormatMilliseconds(DateTime value, string format) => RoundToNearestMillisecond(value).ToString(format, CultureInfo.InvariantCulture); + /// + /// Formats a full-resolution timestamp string for a compact live-grid display while + /// leaving the source string untouched for evidence, search and full-precision tooltips. + /// Non-timestamp values are returned unchanged rather than guessed. + /// + public static string FormatMilliseconds( + string? value, + string format = "yyyy-MM-dd HH:mm:ss.fff", + string missing = "-") + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-") + return text.Length == 0 ? missing : text; + + // Relay timestamps in the live workspace normally have no explicit offset. + // Parse those as DateTime first so presentation never invents or shifts a zone. + if (DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind, + out var dateTime)) + { + return FormatMilliseconds(dateTime, format); + } + + // Preserve explicit-offset timestamps when they are supplied by another surface. + if (DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces, + out var dateTimeOffset)) + { + return FormatMilliseconds(dateTimeOffset, format); + } + + return text; + } + private static long MillisecondDelta(long ticks) { var remainder = ticks % TicksPerMillisecond; diff --git a/IoListTestingWindow.HeaderSelection.cs b/IoListTestingWindow.HeaderSelection.cs new file mode 100644 index 00000000..944f689e --- /dev/null +++ b/IoListTestingWindow.HeaderSelection.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Adds a compact tri-state header checkbox to the FAT TEST column without changing +/// the grid item source or the per-row TestEnabled contract. The header uses the same +/// editability gate as the row checkboxes and persists one bulk plan change. +/// +public partial class IoListTestingWindow +{ + private static readonly bool IoTestHeaderSelectionClassHandlerRegistered = + RegisterIoTestHeaderSelectionClassHandler(); + + private bool _ioTestHeaderSelectionInstallScheduled; + private bool _ioTestHeaderSelectionInstalled; + private CheckBox? _ioTestHeaderSelectionCheckBox; + private IoTestIedPlan? _ioTestHeaderObservedIed; + + private static bool RegisterIoTestHeaderSelectionClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnIoTestHeaderSelectionWindowLoaded), + handledEventsToo: true); + return true; + } + + private static void OnIoTestHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + window._ioTestHeaderSelectionInstalled || + window._ioTestHeaderSelectionInstallScheduled) + { + return; + } + + window._ioTestHeaderSelectionInstallScheduled = true; + window.Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + window._ioTestHeaderSelectionInstallScheduled = false; + window.EnsureIoTestHeaderSelection(); + })); + } + + private void EnsureIoTestHeaderSelection() + { + if (_ioTestHeaderSelectionInstalled) + { + RefreshIoTestHeaderSelection(); + return; + } + + var grid = FindIoTestSelectionGrid(this); + if (grid == null) + return; + + var column = grid.Columns.FirstOrDefault(candidate => + string.Equals(candidate.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase)); + if (column == null) + return; + + var headerCheckBox = new CheckBox + { + Width = 16, + Height = 16, + IsThreeState = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = Cursors.Hand, + Focusable = true, + ToolTip = "Check / uncheck all TEST rows for the selected IED" + }; + AutomationProperties.SetName(headerCheckBox, "Toggle all FAT test rows"); + headerCheckBox.Click += IoTestHeaderSelectionCheckBox_Click; + + column.Header = headerCheckBox; + _ioTestHeaderSelectionCheckBox = headerCheckBox; + _ioTestHeaderSelectionInstalled = true; + + PropertyChanged += IoTestHeaderSelectionWindow_PropertyChanged; + Closed += IoTestHeaderSelectionWindow_Closed; + RewireIoTestHeaderObservedIed(); + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e) + { + var ied = SelectedIed; + if (ied == null || !CanEditPlan) + { + RefreshIoTestHeaderSelection(); + return; + } + + var target = GetIoTestHeaderSelectionState(ied) != true; + foreach (var point in ied.TestPoints) + point.TestEnabled = target; + + Storage?.ScheduleSave(); + Raise(nameof(SelectedIedSummary)); + RaiseSelectedIedContextProperties(); + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedIed)) + RewireIoTestHeaderObservedIed(); + + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderObservedIed_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RefreshIoTestHeaderSelection(); + + private void RewireIoTestHeaderObservedIed() + { + if (ReferenceEquals(_ioTestHeaderObservedIed, SelectedIed)) + return; + + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged; + + _ioTestHeaderObservedIed = SelectedIed; + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged += IoTestHeaderObservedIed_PropertyChanged; + } + + private void RefreshIoTestHeaderSelection() + { + var header = _ioTestHeaderSelectionCheckBox; + if (header == null) + return; + + var ied = SelectedIed; + header.IsEnabled = CanEditPlan && ied?.TestPoints.Count > 0; + header.IsChecked = ied == null ? false : GetIoTestHeaderSelectionState(ied); + } + + private static bool? GetIoTestHeaderSelectionState(IoTestIedPlan ied) + { + if (ied.TestPoints.Count == 0) + return false; + + var enabled = ied.TestPoints.Count(point => point.TestEnabled); + if (enabled == 0) + return false; + if (enabled == ied.TestPoints.Count) + return true; + return null; + } + + private void IoTestHeaderSelectionWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= IoTestHeaderSelectionWindow_PropertyChanged; + Closed -= IoTestHeaderSelectionWindow_Closed; + + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged; + _ioTestHeaderObservedIed = null; + + if (_ioTestHeaderSelectionCheckBox != null) + _ioTestHeaderSelectionCheckBox.Click -= IoTestHeaderSelectionCheckBox_Click; + } + + private static DataGrid? FindIoTestSelectionGrid(DependencyObject root) + { + foreach (var grid in FindIoTestHeaderVisualChildren(root)) + { + if (grid.Columns.Any(column => + string.Equals(column.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase))) + { + return grid; + } + } + + return null; + } + + private static IEnumerable FindIoTestHeaderVisualChildren(DependencyObject root) + where T : DependencyObject + { + if (root == null) + yield break; + + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T match) + yield return match; + + foreach (var descendant in FindIoTestHeaderVisualChildren(child)) + yield return descendant; + } + } +} diff --git a/MainWindow.ExplorerSignalGrid.cs b/MainWindow.ExplorerSignalGrid.cs index 155ccbba..6d7391cb 100644 --- a/MainWindow.ExplorerSignalGrid.cs +++ b/MainWindow.ExplorerSignalGrid.cs @@ -1,7 +1,13 @@ +using System.Globalization; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; using System.Windows.Media; +using System.Windows.Media.Effects; using System.Windows.Threading; +using ArIED61850Tester.Models; +using WpfToolTip = System.Windows.Controls.ToolTip; namespace ArIED61850Tester; @@ -52,6 +58,71 @@ private void ConfigureExplorerSignalGridForCompactFit() signalGrid.Columns[index].MinWidth = minimums[index]; signalGrid.Columns[index].Width = new DataGridLength(weights[index], DataGridLengthUnitType.Star); } + + ConfigureExplorerTimestampPresentation(signalGrid); + } + + private static void ConfigureExplorerTimestampPresentation(DataGrid signalGrid) + { + if (signalGrid.Columns.Count <= 4 || signalGrid.Columns[4] is not DataGridTextColumn timestampColumn) + return; + + // Display only is rounded to nearest millisecond. The monitor point keeps the + // original full-resolution timestamp for evidence, search and hover detail. + timestampColumn.Binding = new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp)) + { + Converter = ExplorerTimestampMillisecondsConverter.Instance, + Mode = BindingMode.OneWay + }; + + var timestampTextStyle = new Style(typeof(TextBlock), timestampColumn.ElementStyle); + timestampTextStyle.Setters.Add(new Setter( + FrameworkElement.ToolTipProperty, + new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp)) + { + Converter = ExplorerTimestampFullPrecisionToolTipConverter.Instance, + Mode = BindingMode.OneWay + })); + timestampTextStyle.Setters.Add(new Setter(ToolTipService.ShowDurationProperty, 30000)); + timestampTextStyle.Setters.Add(new Setter(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis)); + timestampColumn.ElementStyle = timestampTextStyle; + + // Scope the premium tooltip styling to this live-value DataGrid so unrelated + // application tooltips retain their established appearance. + signalGrid.Resources[typeof(WpfToolTip)] = BuildExplorerTimestampToolTipStyle(); + } + + private static Style BuildExplorerTimestampToolTipStyle() + { + var style = new Style(typeof(WpfToolTip)); + style.Setters.Add(new Setter(Control.ForegroundProperty, Brushes.White)); + style.Setters.Add(new Setter(Control.FontSizeProperty, 11.4)); + style.Setters.Add(new Setter(Control.FontWeightProperty, FontWeights.Medium)); + style.Setters.Add(new Setter(WpfToolTip.PlacementProperty, PlacementMode.Mouse)); + style.Setters.Add(new Setter(WpfToolTip.HorizontalOffsetProperty, 10d)); + style.Setters.Add(new Setter(WpfToolTip.VerticalOffsetProperty, 12d)); + + var template = new ControlTemplate(typeof(WpfToolTip)); + var chrome = new FrameworkElementFactory(typeof(Border)); + chrome.SetValue(Border.BackgroundProperty, new SolidColorBrush(Color.FromRgb(35, 49, 59))); + chrome.SetValue(Border.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(91, 111, 123))); + chrome.SetValue(Border.BorderThicknessProperty, new Thickness(1)); + chrome.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + chrome.SetValue(Border.PaddingProperty, new Thickness(11, 8, 11, 8)); + chrome.SetValue(Border.EffectProperty, new DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 4, + Opacity = 0.20, + Color = Color.FromRgb(15, 23, 42) + }); + + var content = new FrameworkElementFactory(typeof(ContentPresenter)); + content.SetValue(ContentPresenter.RecognizesAccessKeyProperty, false); + chrome.AppendChild(content); + template.VisualTree = chrome; + style.Setters.Add(new Setter(Control.TemplateProperty, template)); + return style; } private static IEnumerable FindExplorerVisualChildren(DependencyObject? root) @@ -71,3 +142,30 @@ private static IEnumerable FindExplorerVisualChildren(DependencyObject? ro } } } + +internal sealed class ExplorerTimestampMillisecondsConverter : IValueConverter +{ + public static ExplorerTimestampMillisecondsConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => Iec61850TimestampPresentation.FormatMilliseconds(value as string); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +internal sealed class ExplorerTimestampFullPrecisionToolTipConverter : IValueConverter +{ + public static ExplorerTimestampFullPrecisionToolTipConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = (value as string)?.Trim(); + return string.IsNullOrWhiteSpace(text) || text == "-" + ? "IED TIMESTAMP · FULL PRECISION\nNo timestamp available" + : $"IED TIMESTAMP · FULL PRECISION\n{text}"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index c6aab0b1..008c6612 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -136,6 +136,12 @@ void ReportProgress(string message) ReportProgress($"{ied.IedName} association ready · reusing the loaded model"); } + // Reconciliation belongs to the connection/discovery lifecycle, before local + // FAT row selection. P1.2 remains probe:null inside the cache; P1.3 can later + // replace only that producer with the engine-owned connected facade. + ReportProgress("Reconciling SCL design with authoritative live model"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + // Never let a runtime anchor from an earlier model silently decide a fresh // FAT preparation. Re-prove every requested row against the current model; // successful smart matches are anchored again immediately below. @@ -175,6 +181,9 @@ void ReportProgress(string message) } usedSavedModel = false; + ReportProgress("Reconciling refreshed live model with SCL design"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + foreach (var point in requestedPoints) { point.ApplyLiveBinding( diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs new file mode 100644 index 00000000..8250a27a --- /dev/null +++ b/MainWindow.LiveSignalSearch.cs @@ -0,0 +1,313 @@ +using System.Collections; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Lightweight, presentation-only search for the Explorer live-value workspace. +/// Filtering is performed over the existing ICollectionView; it never changes the +/// monitored point collection, acquisition lifecycle, or IEC 61850 network traffic. +/// +public partial class MainWindow +{ + private static readonly bool LiveSignalSearchClassHandlerRegistered = RegisterLiveSignalSearchClassHandler(); + + private DataGrid? _liveSignalSearchGrid; + private TextBox? _liveSignalSearchBox; + private TextBlock? _liveSignalSearchCount; + private ICollectionView? _liveSignalSearchView; + private INotifyCollectionChanged? _liveSignalSearchCollection; + private DependencyPropertyDescriptor? _liveSignalItemsSourceDescriptor; + private bool _liveSignalSearchInstalled; + + private static bool RegisterLiveSignalSearchClassHandler() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(LiveSignalSearch_MainWindowLoaded)); + return true; + } + + private static void LiveSignalSearch_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || !ReferenceEquals(e.OriginalSource, window)) + return; + + window.Dispatcher.BeginInvoke(new Action(window.InstallLiveSignalSearch)); + } + + private void InstallLiveSignalSearch() + { + if (_liveSignalSearchInstalled) + return; + + var dataGrid = FindLiveSignalVisualChildren(this) + .FirstOrDefault(IsExplorerLiveSignalGrid); + if (dataGrid?.Parent is not Grid host) + return; + + _liveSignalSearchInstalled = true; + _liveSignalSearchGrid = dataGrid; + + // The existing host contains the DataGrid plus its empty-workspace overlay. + // Give both row 1 and reserve a compact row 0 for the search toolbar. + if (host.RowDefinitions.Count == 0) + { + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + foreach (UIElement child in host.Children.Cast().ToArray()) + Grid.SetRow(child, 1); + } + + var toolbar = BuildLiveSignalSearchToolbar(); + Grid.SetRow(toolbar, 0); + host.Children.Add(toolbar); + + _liveSignalItemsSourceDescriptor = DependencyPropertyDescriptor.FromProperty( + ItemsControl.ItemsSourceProperty, + typeof(DataGrid)); + _liveSignalItemsSourceDescriptor?.AddValueChanged(dataGrid, LiveSignalSearch_ItemsSourceChanged); + + PreviewKeyDown += LiveSignalSearch_WindowPreviewKeyDown; + Closed += LiveSignalSearch_WindowClosed; + AttachLiveSignalSearchSource(); + } + + private FrameworkElement BuildLiveSignalSearchToolbar() + { + var toolbar = new Grid + { + Margin = new Thickness(2, 0, 2, 9), + Height = 34 + }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titlePanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + titlePanel.Children.Add(new Border + { + Width = 7, + Height = 7, + CornerRadius = new CornerRadius(3.5), + Background = new SolidColorBrush(Color.FromRgb(37, 99, 235)), + Margin = new Thickness(2, 0, 8, 0) + }); + titlePanel.Children.Add(new TextBlock + { + Text = "LIVE SIGNAL VALUES", + FontSize = 11.2, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(70, 86, 109)), + VerticalAlignment = VerticalAlignment.Center + }); + _liveSignalSearchCount = new TextBlock + { + Text = string.Empty, + FontSize = 10.8, + Foreground = new SolidColorBrush(Color.FromRgb(126, 142, 165)), + Margin = new Thickness(9, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + titlePanel.Children.Add(_liveSignalSearchCount); + toolbar.Children.Add(titlePanel); + + _liveSignalSearchBox = new TextBox + { + Width = 390, + Style = ResolveIndustrialSearchStyle(), + Tag = "Search signal, IEC reference, value, quality or acquisition", + ToolTip = "Filter the current live workspace. Ctrl+F focuses search; Esc clears it.", + VerticalAlignment = VerticalAlignment.Center + }; + _liveSignalSearchBox.TextChanged += LiveSignalSearch_TextChanged; + _liveSignalSearchBox.PreviewKeyDown += LiveSignalSearch_BoxPreviewKeyDown; + Grid.SetColumn(_liveSignalSearchBox, 1); + toolbar.Children.Add(_liveSignalSearchBox); + + return toolbar; + } + + private Style? ResolveIndustrialSearchStyle() + { + var style = TryFindResource("IndustrialSearchTextBox") as Style + ?? Application.Current?.TryFindResource("IndustrialSearchTextBox") as Style; + if (style != null) + return style; + + // Defensive fallback for load ordering: use the same resource dictionary that + // P2IndustrialWorkstationUx uses for the IED Explorer search field. + var dictionary = new ResourceDictionary + { + Source = new Uri("/ARSAS;component/Resources/P2IndustrialControls.xaml", UriKind.Relative) + }; + return dictionary["IndustrialSearchTextBox"] as Style; + } + + private static bool IsExplorerLiveSignalGrid(DataGrid grid) + { + var headers = grid.Columns + .Select(column => column.Header?.ToString() ?? string.Empty) + .ToArray(); + + // This six-column signature is unique to the selected-IED live-value workspace. + // The global monitor has an additional IED column and the command grid has a + // different schema, so no visual-tree TabItem assumptions are required. + return headers.Length == 6 && + headers[0].Equals("Signal", StringComparison.OrdinalIgnoreCase) && + headers[1].Equals("IEC Telegram", StringComparison.OrdinalIgnoreCase) && + headers[2].Equals("Value", StringComparison.OrdinalIgnoreCase) && + headers[3].Equals("Quality", StringComparison.OrdinalIgnoreCase) && + headers[4].Equals("IED Timestamp", StringComparison.OrdinalIgnoreCase) && + headers[5].Equals("Acquisition", StringComparison.OrdinalIgnoreCase); + } + + private void LiveSignalSearch_ItemsSourceChanged(object? sender, EventArgs e) + => AttachLiveSignalSearchSource(); + + private void AttachLiveSignalSearchSource() + { + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged -= LiveSignalSearch_CollectionChanged; + if (_liveSignalSearchView != null) + _liveSignalSearchView.Filter = null; + + var source = _liveSignalSearchGrid?.ItemsSource; + _liveSignalSearchCollection = source as INotifyCollectionChanged; + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged += LiveSignalSearch_CollectionChanged; + + _liveSignalSearchView = source == null + ? null + : CollectionViewSource.GetDefaultView(source); + ApplyLiveSignalSearch(); + } + + private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e) + => ApplyLiveSignalSearch(); + + private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => Dispatcher.BeginInvoke(new Action(UpdateLiveSignalSearchCount)); + + private void ApplyLiveSignalSearch() + { + if (_liveSignalSearchView == null) + { + UpdateLiveSignalSearchCount(); + return; + } + + _liveSignalSearchView.Filter = LiveSignalSearch_Matches; + _liveSignalSearchView.Refresh(); + UpdateLiveSignalSearchCount(); + } + + private bool LiveSignalSearch_Matches(object item) + { + var query = (_liveSignalSearchBox?.Text ?? string.Empty).Trim(); + if (query.Length == 0) + return true; + if (item is not Iec61850MonitorPoint point) + return true; + + var searchable = string.Join('\n', new[] + { + point.DeviceName, + point.SignalName, + point.IecTelegram, + point.IecReference, + point.Value, + point.Quality, + point.DeviceTimestamp, + point.SourceMode + }.Where(value => !string.IsNullOrWhiteSpace(value))); + + // Multiple terms use AND semantics: "MMXU instCVal" quickly narrows large workspaces. + var tokens = query.Split( + new[] { ' ', '\t', '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return tokens.All(token => searchable.Contains(token, StringComparison.OrdinalIgnoreCase)); + } + + private void UpdateLiveSignalSearchCount() + { + if (_liveSignalSearchCount == null) + return; + + var source = _liveSignalSearchGrid?.ItemsSource; + var total = source switch + { + ICollection collection => collection.Count, + IEnumerable enumerable => enumerable.Count(), + _ => 0 + }; + var visible = _liveSignalSearchView?.Cast().Count() ?? total; + var filtered = !string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text); + _liveSignalSearchCount.Text = filtered + ? $"{visible:N0} of {total:N0} shown" + : $"{total:N0} signals"; + } + + private void LiveSignalSearch_WindowPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.F && Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && + MainTabs?.SelectedIndex == 0 && _liveSignalSearchBox != null) + { + _liveSignalSearchBox.Focus(); + _liveSignalSearchBox.SelectAll(); + e.Handled = true; + } + } + + private void LiveSignalSearch_BoxPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Escape) + return; + ClearLiveSignalSearch(); + e.Handled = true; + } + + private void ClearLiveSignalSearch() + { + if (_liveSignalSearchBox == null) + return; + _liveSignalSearchBox.Clear(); + _liveSignalSearchBox.Focus(); + } + + private void LiveSignalSearch_WindowClosed(object? sender, EventArgs e) + { + PreviewKeyDown -= LiveSignalSearch_WindowPreviewKeyDown; + Closed -= LiveSignalSearch_WindowClosed; + if (_liveSignalSearchGrid != null) + _liveSignalItemsSourceDescriptor?.RemoveValueChanged(_liveSignalSearchGrid, LiveSignalSearch_ItemsSourceChanged); + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged -= LiveSignalSearch_CollectionChanged; + if (_liveSignalSearchView != null) + _liveSignalSearchView.Filter = null; + } + + private static IEnumerable FindLiveSignalVisualChildren(DependencyObject root) + where T : DependencyObject + { + for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T typed) + yield return typed; + foreach (var descendant in FindLiveSignalVisualChildren(child)) + yield return descendant; + } + } +} diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index a7534ff7..96193280 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; @@ -72,9 +73,13 @@ private static IoTestLiveBindingSummary BindPlans( device.IsConnected, device.IsMonitoring); + // Binding is intentionally cache-only. Reconciliation production is async and + // happens in the FAT/session lifecycle; this path must never perform MMS reads + // or block the UI while an IED is slow. + var reconciliation = BuildEngineReconciliation(device); foreach (var point in iedPlan.TestPoints) { - var binding = BindPoint(point, device); + var binding = BindPoint(point, device, reconciliation); point.ApplyLiveBinding(binding.State, binding.Reason, device.DeviceId, binding.Reference); if (point.IsLiveBound) signalBoundCount++; @@ -93,6 +98,7 @@ private static IoTestLiveBindingSummary BindPlans( } else if (binding.State == IoTestLiveBindingState.SignalNotFound) { + // SignalNotFound is reserved for ARIEC61850's confirmed Absent verdict. missingSignalCount++; } } @@ -107,18 +113,48 @@ private static IoTestLiveBindingSummary BindPlans( missingSignalCount); } - private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevice device) + private static EngineReconciliationContext BuildEngineReconciliation(Iec61850MonitorDevice device) + { + var cached = IoTestReconciliationCache.Get(device); + return new EngineReconciliationContext(cached.Document, cached.FailureReason); + } + + private static PointBinding BindPoint( + IoTestPointPlan point, + Iec61850MonitorDevice device, + EngineReconciliationContext reconciliation) { var importedReferences = ImportedReferences(point); if (!point.ImportReady || importedReferences.Count == 0) { return new PointBinding( - IoTestLiveBindingState.SignalNotFound, - "The imported row is not ready for automatic live binding.", + IoTestLiveBindingState.NotEvaluated, + "The imported row is not ready for automatic live binding; no absence conclusion was made.", string.Empty, null); } + var enginePoint = FindEngineReconciliationPoint( + point, + device, + importedReferences, + reconciliation.Document, + out var engineAmbiguous); + var enginePresentation = enginePoint == null + ? null + : IoTestReconciliationPresentation.FromEnginePoint(enginePoint); + + // Only ARIEC61850 may prove true absence. Never let cached/discovered application + // rows override a confirmed engine Absent verdict. + if (enginePresentation?.IsConfirmedAbsent == true) + { + return new PointBinding( + IoTestLiveBindingState.SignalNotFound, + enginePresentation.Reason, + enginePresentation.Reference, + null); + } + var expectedReferences = importedReferences .Select(NormalizeReference) .Where(value => value.Length > 0) @@ -131,7 +167,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Exact imported or prepared IEC 61850 reference is already active in the live monitor.", + WithEngineEvidence( + "Exact imported or prepared IEC 61850 reference is already active in the live monitor.", + enginePresentation), exactLivePoints[0].IecReference, exactLivePoints[0]); } @@ -144,7 +182,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.BoundExact, - "Exact imported or prepared IEC 61850 reference is present in the discovered IED model.", + WithEngineEvidence( + "Exact imported or prepared IEC 61850 reference is present in the ARSAS signal workspace.", + enginePresentation), exactSignals[0].ObjectReference, null); } @@ -159,7 +199,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Live point matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", + WithEngineEvidence( + "One existing ARSAS live row matched the imported FAT row; protocol absence status remains owned by ARIEC reconciliation.", + enginePresentation), livePointCandidates[0].IecReference, livePointCandidates[0]); } @@ -174,20 +216,105 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.BoundNormalized, - "Discovered signal matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", + WithEngineEvidence( + "One existing ARSAS signal row matched the imported FAT row; protocol absence status remains owned by ARIEC reconciliation.", + enginePresentation), signalCandidates[0].ObjectReference, null); } - var reason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || - signalCandidates.Count > 1 || livePointCandidates.Count > 1 - ? "More than one equally strong IEC 61850 candidate matched the imported telegram; automatic binding was withheld." + if (enginePresentation != null) + { + return new PointBinding( + enginePresentation.State, + enginePresentation.Reason, + enginePresentation.Reference, + null); + } + + var localReason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || + signalCandidates.Count > 1 || livePointCandidates.Count > 1 + ? "More than one equally strong ARSAS row matched the imported FAT point; automatic live-row binding was withheld." : device.Signals.Count == 0 - ? "The IED is loaded but its signal model has not been discovered yet." - : "None of the imported IEC 61850/event-log references was found in the loaded IED model after conservative canonical matching."; - return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null); + ? "The IED is loaded but its ARSAS signal workspace has not been populated yet." + : "No unique ARSAS live/signal row matched this imported FAT point."; + + var engineReason = engineAmbiguous + ? "More than one ARIEC reconciliation point matched the imported row; no absence conclusion was made." + : string.IsNullOrWhiteSpace(reconciliation.FailureReason) + ? "No unique ARIEC reconciliation point was associated with this imported row; no absence conclusion was made." + : reconciliation.FailureReason + " No absence conclusion was made."; + + // A local lookup miss is explicitly NotEvaluated. It is never SignalNotFound. + return new PointBinding( + IoTestLiveBindingState.NotEvaluated, + $"{localReason} {engineReason}", + string.Empty, + null); + } + + private static Iec61850DesignLivePointReconciliation? FindEngineReconciliationPoint( + IoTestPointPlan point, + Iec61850MonitorDevice device, + IReadOnlyCollection importedReferences, + Iec61850DesignLiveReconciliationDocument? document, + out bool ambiguous) + { + ambiguous = false; + if (document == null || document.Points.Count == 0) + return null; + + var scored = document.Points + .Select(candidate => new + { + Candidate = candidate, + Score = EngineReferences(candidate) + .Select(engineReference => importedReferences.Max(importedReference => + IoTestReferenceMatcher.Score( + importedReference, + engineReference, + point.IedName, + device.Name, + device.SclIedName, + point.LogicalNode))) + .DefaultIfEmpty(0) + .Max() + }) + .Where(item => item.Score > 0) + .ToList(); + + if (scored.Count == 0) + return null; + + var bestScore = scored.Max(item => item.Score); + var best = scored.Where(item => item.Score == bestScore).Select(item => item.Candidate).ToList(); + ambiguous = best.Count > 1; + return best.Count == 1 ? best[0] : null; } + private static IEnumerable EngineReferences(Iec61850DesignLivePointReconciliation point) + { + if (!string.IsNullOrWhiteSpace(point.Reference)) + yield return point.Reference; + if (!string.IsNullOrWhiteSpace(point.MmsReference)) + yield return point.MmsReference; + if (!string.IsNullOrWhiteSpace(point.CanonicalMmsReference)) + yield return point.CanonicalMmsReference; + if (!string.IsNullOrWhiteSpace(point.EffectiveMmsReference)) + yield return point.EffectiveMmsReference; + if (!string.IsNullOrWhiteSpace(point.ObservedReference)) + yield return point.ObservedReference; + if (!string.IsNullOrWhiteSpace(point.ObservedMmsReference)) + yield return point.ObservedMmsReference; + } + + private static string WithEngineEvidence( + string localReason, + IoTestReconciliationPresentationResult? enginePresentation) + => enginePresentation == null + ? localReason + : $"{localReason} {enginePresentation.Reason}"; + private static bool IsSignalEligible(SignalDefinition signal, IoTestPointPlan point) { if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) @@ -245,10 +372,9 @@ void Add(string? value) Add(point.ReportDisplayReference); // During FAT preparation the signal-selection pass may prove one unique live - // model reference from otherwise incomplete source metadata (for example a - // legacy 7SX80 ANSI-27 row). Keep that exact prepared reference authoritative - // for subsequent model/live-point binding. It is transient runtime state and is - // cleared automatically whenever ApplyLiveBinding reports a non-bound result. + // model reference from otherwise incomplete source metadata. Keep that exact + // prepared row available for subsequent UI/live-point lookup only; it is not + // evidence that an IEC 61850 design point is present or absent. if (point.IsLiveBound) Add(point.LiveSignalReference); @@ -323,6 +449,10 @@ internal static IReadOnlySet NormalizeImportedTelegramForms( string? logicalNode) => IoTestReferenceMatcher.ImportedForms(reference, iedName, logicalNode); + private sealed record EngineReconciliationContext( + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason); + private sealed record PointBinding( IoTestLiveBindingState State, string Reason, diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs new file mode 100644 index 00000000..7a272401 --- /dev/null +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Owns the ARSAS-side lifecycle of engine reconciliation documents. +/// +/// Reconciliation production is asynchronous and cancellable; synchronous FAT/UI binding +/// only reads the latest document for the exact design/live model object pair. Production +/// refreshes delegate to the native session owner, which in turn calls the ARIEC connected +/// facade. FAT/UI code never owns an MMS session, an exact-read probe, or protocol failure +/// classification. +/// +public static class IoTestReconciliationCache +{ + private static readonly ConcurrentDictionary Entries = new(); + + /// + /// Production refresh: when a native session owner exists for this endpoint, use its + /// already-active association and let ARIEC61850 own exact reads, alternate strategies, + /// probe budgets, and failure verdicts. A workspace that has never created a session may + /// still build a model-only document; that path can only remain DesignOnly, never Absent. + /// + public static Task RefreshAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + + if (!NativeIec61850Client.HasReconciliationOwner(device.IpAddress, device.Port)) + return RefreshModelOnlyAsync(device, cancellationToken); + + return RefreshAsync( + device, + (design, live, token) => NativeIec61850Client.ReconcileConnectedAsync( + device.IpAddress, + device.Port, + design, + live, + options: null, + cancellationToken: token), + cancellationToken); + } + + /// + /// Explicit model-only refresh for deterministic tests and offline model inspection. + /// It never produces protocol absence because no exact read probe is supplied. + /// + public static Task RefreshModelOnlyAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + => RefreshAsync( + device, + static (design, live, token) => Iec61850DesignLiveReconciler.ReconcileAsync( + design, + live, + probe: null, + cancellationToken: token), + cancellationToken); + + public static async Task RefreshAsync( + Iec61850MonitorDevice device, + Func> producer, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(producer); + + var designModel = device.SclWorkspace?.DesignModel; + var liveModel = device.LiveDiscoveryModel; + if (designModel == null || liveModel == null) + { + Entries.TryRemove(device, out _); + return; + } + + try + { + var document = await producer(designModel, liveModel, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // Do not publish a document for a model generation that changed while the + // reconciliation task was running. The next refresh will reconcile the new pair. + if (!ReferenceEquals(device.SclWorkspace?.DesignModel, designModel) || + !ReferenceEquals(device.LiveDiscoveryModel, liveModel)) + { + return; + } + + Entries[device] = new CacheEntry( + designModel, + liveModel, + document, + string.Empty, + DateTimeOffset.UtcNow); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (ReferenceEquals(device.SclWorkspace?.DesignModel, designModel) && + ReferenceEquals(device.LiveDiscoveryModel, liveModel)) + { + Entries[device] = new CacheEntry( + designModel, + liveModel, + null, + $"ARIEC reconciliation could not be produced asynchronously: {ex.GetType().Name}: {ex.Message}", + DateTimeOffset.UtcNow); + } + } + } + + /// + /// Refreshes IEDs sequentially by design. The sequential contract prevents a project + /// refresh from turning bounded relay verification into an uncontrolled multi-IED probe storm. + /// + public static async Task RefreshAsync( + IEnumerable devices, + Func> producer, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(devices); + ArgumentNullException.ThrowIfNull(producer); + + foreach (var device in devices.Distinct()) + { + cancellationToken.ThrowIfCancellationRequested(); + await RefreshAsync( + device, + (design, live, token) => producer(device, design, live, token), + cancellationToken) + .ConfigureAwait(false); + } + } + + public static IoTestReconciliationCacheSnapshot Get(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var designModel = device.SclWorkspace?.DesignModel; + if (designModel == null) + { + return new IoTestReconciliationCacheSnapshot( + null, + "No SCL design model is attached to this ARSAS IED workspace.", + null, + false); + } + + var liveModel = device.LiveDiscoveryModel; + if (liveModel == null) + { + return new IoTestReconciliationCacheSnapshot( + null, + "No authoritative ARIEC live discovery model is available yet.", + null, + false); + } + + if (!Entries.TryGetValue(device, out var entry) || + !ReferenceEquals(entry.DesignModel, designModel) || + !ReferenceEquals(entry.LiveModel, liveModel)) + { + return new IoTestReconciliationCacheSnapshot( + null, + "ARIEC reconciliation cache is not ready for the current design/live model generation.", + null, + false); + } + + return new IoTestReconciliationCacheSnapshot( + entry.Document, + entry.FailureReason, + entry.ProducedAtUtc, + true); + } + + public static void Invalidate(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Entries.TryRemove(device, out _); + } + + private sealed record CacheEntry( + object DesignModel, + object LiveModel, + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason, + DateTimeOffset ProducedAtUtc); +} + +public sealed record IoTestReconciliationCacheSnapshot( + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason, + DateTimeOffset? ProducedAtUtc, + bool IsCurrent); diff --git a/Services/IoTesting/IoTestReconciliationPresentation.cs b/Services/IoTesting/IoTestReconciliationPresentation.cs new file mode 100644 index 00000000..6808e960 --- /dev/null +++ b/Services/IoTesting/IoTestReconciliationPresentation.cs @@ -0,0 +1,121 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Presentation-only mapping from ARIEC61850 reconciliation verdicts to the existing +/// FAT live-binding state. Protocol semantics, reference canonicalization and probe +/// failure interpretation remain entirely in ARIEC61850. +/// +public static class IoTestReconciliationPresentation +{ + public static IoTestReconciliationPresentationResult FromEnginePoint( + Iec61850DesignLivePointReconciliation point) + { + ArgumentNullException.ThrowIfNull(point); + + var state = point.Status switch + { + Iec61850DesignLiveStatus.Exact => IoTestLiveBindingState.BoundExact, + Iec61850DesignLiveStatus.Compatible => IoTestLiveBindingState.BoundNormalized, + Iec61850DesignLiveStatus.RecoveredByProbe => IoTestLiveBindingState.BoundExact, + Iec61850DesignLiveStatus.RecoveredByAlternateProbe => IoTestLiveBindingState.BoundNormalized, + Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery => IoTestLiveBindingState.BoundNormalized, + // SignalNotFound is deliberately reserved for an engine-confirmed Absent verdict. + Iec61850DesignLiveStatus.Absent => IoTestLiveBindingState.SignalNotFound, + _ => IoTestLiveBindingState.NotEvaluated + }; + + var statusText = point.Status switch + { + Iec61850DesignLiveStatus.Exact => "ARIEC status: Exact", + Iec61850DesignLiveStatus.Compatible => "ARIEC status: Compatible", + Iec61850DesignLiveStatus.RecoveredByProbe => "ARIEC status: RecoveredByProbe", + Iec61850DesignLiveStatus.RecoveredByAlternateProbe => "ARIEC status: RecoveredByAlternateProbe", + Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery => "ARIEC status: RecoveredByAlternateDiscovery", + Iec61850DesignLiveStatus.DesignOnly => point.ProbeDeferredByBudget + ? "ARIEC status: DesignOnly · verification deferred by probe budget" + : "ARIEC status: DesignOnly · exact verification required", + Iec61850DesignLiveStatus.LiveOnly => "ARIEC status: LiveOnly", + Iec61850DesignLiveStatus.FunctionalConstraintMismatch => "ARIEC status: FunctionalConstraintMismatch", + Iec61850DesignLiveStatus.TypeMismatch => "ARIEC status: TypeMismatch", + Iec61850DesignLiveStatus.Ambiguous => "ARIEC status: Ambiguous", + Iec61850DesignLiveStatus.InvalidTarget => "ARIEC status: InvalidTarget", + Iec61850DesignLiveStatus.Unreadable => "ARIEC status: Unreadable", + Iec61850DesignLiveStatus.Absent => "ARIEC status: Absent · protocol-confirmed signal absence", + Iec61850DesignLiveStatus.TransportFailure => "ARIEC status: TransportFailure", + Iec61850DesignLiveStatus.UnresolvedDesign => "ARIEC status: UnresolvedDesign", + _ => $"ARIEC status: {point.Status}" + }; + + var evidence = new List { statusText }; + + var canonical = FirstNonEmpty(point.CanonicalMmsReference, point.MmsReference); + var effective = FirstNonEmpty( + point.EffectiveMmsReference, + point.ObservedMmsReference, + point.ObservedReference, + canonical); + + if (!string.IsNullOrWhiteSpace(canonical)) + evidence.Add($"Canonical: {canonical}"); + if (!string.IsNullOrWhiteSpace(effective)) + evidence.Add($"Effective: {effective}"); + if (point.AlternateStrategy.HasValue) + evidence.Add($"Alternate strategy: {point.AlternateStrategy.Value}"); + if (point.ProbeDeferredByBudget) + evidence.Add("Verification deferred by ARIEC probe budget; no absence conclusion was made"); + + foreach (var item in point.Evidence.Where(item => !string.IsNullOrWhiteSpace(item))) + evidence.Add(item.Trim()); + + if (point.Probe != null) + AppendProbeEvidence(evidence, "Final probe", point.Probe); + + for (var i = 0; i < point.ProbeAttempts.Count; i++) + { + var attempt = point.ProbeAttempts[i]; + var kind = attempt.IsCanonical + ? "canonical" + : attempt.AlternateStrategy.HasValue + ? $"alternate/{attempt.AlternateStrategy.Value}" + : "alternate"; + evidence.Add($"Probe attempt {i + 1}: {kind}"); + if (!string.IsNullOrWhiteSpace(attempt.Explanation)) + evidence.Add(attempt.Explanation.Trim()); + AppendProbeEvidence(evidence, $"Attempt {i + 1}", attempt.Probe); + } + + return new IoTestReconciliationPresentationResult( + state, + string.Join(" · ", evidence.Distinct(StringComparer.OrdinalIgnoreCase)), + effective, + point.Status == Iec61850DesignLiveStatus.Absent); + } + + private static void AppendProbeEvidence( + ICollection evidence, + string label, + Iec61850ExactProbeEvidence probe) + { + evidence.Add($"{label}: {probe.Status}"); + if (!string.IsNullOrWhiteSpace(probe.MmsReference)) + evidence.Add($"{label} target: {probe.MmsReference}"); + if (!string.IsNullOrWhiteSpace(probe.Message)) + evidence.Add(probe.Message.Trim()); + if (!string.IsNullOrWhiteSpace(probe.ValueSummary)) + evidence.Add($"{label} value: {probe.ValueSummary}"); + if (probe.FailureCode.HasValue) + evidence.Add($"{label} engine failure code: {probe.FailureCode.Value}"); + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} + +public sealed record IoTestReconciliationPresentationResult( + IoTestLiveBindingState State, + string Reason, + string Reference, + bool IsConfirmedAbsent); diff --git a/Services/NativeIec61850Client.Reconciliation.cs b/Services/NativeIec61850Client.Reconciliation.cs new file mode 100644 index 00000000..f2de1125 --- /dev/null +++ b/Services/NativeIec61850Client.Reconciliation.cs @@ -0,0 +1,142 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +/// +/// Session-owner bridge for ARIEC61850 connected reconciliation. +/// The raw MMS session never leaves NativeIec61850Client; ARSAS callers receive only +/// the engine reconciliation document and never construct probes or classify MMS failures. +/// +public sealed partial class NativeIec61850Client +{ + private static readonly object ReconciliationOwnerRegistryGate = new(); + private static readonly List> ReconciliationOwners = new(); + private static long _nextReconciliationOwnerSequence; + private readonly long _reconciliationOwnerSequence; + + public NativeIec61850Client() + { + _reconciliationOwnerSequence = Interlocked.Increment(ref _nextReconciliationOwnerSequence); + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + ReconciliationOwners.Add(new WeakReference(this)); + } + } + + /// + /// Runs the engine-owned connected reconciliation pipeline against this client's + /// already-owned MMS association. No second association is created. + /// + public Task ReconcileDesignLiveAsync( + LiveIedModelDiscoveryDocument designModel, + LiveIedModelDiscoveryDocument liveModel, + Iec61850DesignLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(designModel); + ArgumentNullException.ThrowIfNull(liveModel); + + var service = new Iec61850ConnectedReconciliationService(_session); + + // When the association is active, serialize reconciliation reads with the other + // native MMS work owned by this client. When it is already down, call the engine + // facade directly so ARIEC can return TransportFailure rather than an ARSAS-side + // lifecycle guess or an ObjectDisposedException from the client's I/O gate. + return _session.IsMmsInitiated + ? RunMmsOperationAsync( + () => service.ReconcileAsync(designModel, liveModel, options, cancellationToken), + cancellationToken) + : service.ReconcileAsync(designModel, liveModel, options, cancellationToken); + } + + /// + /// Resolves the NativeIec61850Client that already owns the requested endpoint and + /// delegates to its ARIEC connected facade. Multiple simultaneously active owners are + /// rejected instead of guessing which association belongs to the FAT workflow. + /// + public static Task ReconcileConnectedAsync( + string ipAddress, + int port, + LiveIedModelDiscoveryDocument designModel, + LiveIedModelDiscoveryDocument liveModel, + Iec61850DesignLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress); + ArgumentNullException.ThrowIfNull(designModel); + ArgumentNullException.ThrowIfNull(liveModel); + + var owner = ResolveReconciliationOwner(ipAddress, port); + return owner.ReconcileDesignLiveAsync( + designModel, + liveModel, + options, + cancellationToken); + } + + internal static bool HasReconciliationOwner(string? ipAddress, int port) + { + var host = (ipAddress ?? string.Empty).Trim(); + if (host.Length == 0) + return false; + var normalizedPort = port <= 0 ? 102 : port; + + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + return ReconciliationOwners.Any(reference => + reference.TryGetTarget(out var client) && + client._host.Equals(host, StringComparison.OrdinalIgnoreCase) && + client._port == normalizedPort); + } + } + + private static NativeIec61850Client ResolveReconciliationOwner(string ipAddress, int port) + { + var host = ipAddress.Trim(); + var normalizedPort = port <= 0 ? 102 : port; + + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + var matches = ReconciliationOwners + .Select(reference => reference.TryGetTarget(out var client) ? client : null) + .Where(client => client != null && + client._host.Equals(host, StringComparison.OrdinalIgnoreCase) && + client._port == normalizedPort) + .Cast() + .OrderByDescending(client => client._reconciliationOwnerSequence) + .ToList(); + + var active = matches.Where(client => client.IsConnected).ToList(); + if (active.Count == 1) + return active[0]; + + if (active.Count > 1) + { + throw new InvalidOperationException( + $"More than one active native MMS association owns {host}:{normalizedPort}; connected reconciliation was withheld rather than guessing the FAT session."); + } + + if (matches.Count > 0) + { + // Preserve the newest session owner after a disconnect so the ARIEC + // connected facade can classify the session state as TransportFailure. + return matches[0]; + } + } + + throw new InvalidOperationException( + $"No native MMS session owner is registered for {host}:{normalizedPort}; connected reconciliation cannot run until the IED session exists."); + } + + private static void PruneReconciliationOwnersLocked() + { + for (var index = ReconciliationOwners.Count - 1; index >= 0; index--) + { + if (!ReconciliationOwners[index].TryGetTarget(out _)) + ReconciliationOwners.RemoveAt(index); + } + } +} diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index b1c2d02c..ec14e7a0 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -17,7 +17,7 @@ namespace ArIED61850Tester.Services; /// /// Native IEC 61850 MMS client backed by the ARIEC61850 engine. /// -public sealed class NativeIec61850Client : IIec61850Client, IIec61850ControlClient +public sealed partial class NativeIec61850Client : IIec61850Client, IIec61850ControlClient { private readonly ArMms.MmsClientSession _session = new(); private readonly SemaphoreSlim _mmsIoGate = new(1, 1); diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 43765e7a..03cd9c9e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d041a1e05c2082f966b1e06a977b2f67261fea02", - "sourcePullRequest": 60, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Production baseline includes engine-owned DataSet semantic binding plus design/live reconciliation and exact targeted MMS probe APIs." + "commit": "b60d1272345ec7eabde9efb4e0acbfe26ebf2584", + "sourcePullRequest": 71, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes engine-owned connected reconciliation plus merged COMTRADE/MMS FileOpen interoperability through PR #71, including canonical nested-path handling, bounded directory revalidation/recovery, preservation of the server-returned raw FileDirectory GraphicString identity, and one bounded evidence-driven replay of that exact identity. ARSAS does not invent MMS file-path semantics." } diff --git a/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs b/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs index a2ef0bc4..4398d6b4 100644 --- a/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs +++ b/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs @@ -34,6 +34,23 @@ public void CustomerCase_31Point2006_IsPresentedAs31Point201() Iec61850TimestampPresentation.FormatMilliseconds(timestamp, "ss.fff")); } + [Fact] + public void LiveWorkspaceString_RoundsToThreeFractionalDigits_WithoutChangingSource() + { + const string fullPrecision = "2026-08-14 13:48:12.9165859"; + + var display = Iec61850TimestampPresentation.FormatMilliseconds(fullPrecision); + + Assert.Equal("2026-08-14 13:48:12.917", display); + Assert.Equal("2026-08-14 13:48:12.9165859", fullPrecision); + } + + [Theory] + [InlineData("-", "-")] + [InlineData("relay timestamp unavailable", "relay timestamp unavailable")] + public void LiveWorkspaceString_NonTimestampValues_AreNotInvented(string source, string expected) + => Assert.Equal(expected, Iec61850TimestampPresentation.FormatMilliseconds(source)); + [Fact] public void Rounding_CarriesAcrossSecondAndMinuteBoundary() { diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index de25160c..0a30ef90 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -1,3 +1,5 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; @@ -24,6 +26,7 @@ public void ExactDiscoveredSignal_IsBoundToImportedPoint() Assert.Equal(1, summary.DeviceBoundCount); Assert.Equal(1, summary.SignalBoundCount); + Assert.Equal(0, summary.MissingSignalCount); Assert.Equal(IoTestLiveBindingState.BoundExact, project.Ieds[0].TestPoints[0].LiveBindingState); } @@ -59,7 +62,6 @@ public void ApplicationFolderHierarchy_IsNormalizedToLiveLnPrefix() _binding.Bind(project, new[] { device }); Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); - Assert.Contains("verified functional-group/LN boundary", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -85,7 +87,7 @@ public void PartialTcsLeaf_IsBoundUniquelyToDiscoveredSignal() } [Fact] - public void PartialTcsLeaf_DuplicateObjectsRemainBlocked() + public void PartialTcsLeaf_DuplicateObjectsRemainUnverified_NotMissing() { var project = Project(".TCS1Fail"); var device = Device(); @@ -105,9 +107,30 @@ public void PartialTcsLeaf_DuplicateObjectsRemainBlocked() var summary = _binding.Bind(project, new[] { device }); Assert.Equal(0, summary.SignalBoundCount); - Assert.Equal(1, summary.MissingSignalCount); - Assert.Equal(IoTestLiveBindingState.SignalNotFound, project.Ieds[0].TestPoints[0].LiveBindingState); - Assert.Contains("more than one equally strong", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.Contains("no absence conclusion", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NoLocalCandidate_RemainsUnverified_NotMissing() + { + var project = Project("AA1C1F03R4ADD/GGIO6.Unknown.stVal"); + var device = Device(); + device.Signals.Add(new SignalDefinition + { + Name = "Different signal", + ObjectReference = "AA1C1F03R4ADD/GGIO6.Other.stVal", + FunctionalConstraint = "ST" + }); + + var summary = _binding.Bind(project, new[] { device }); + + var point = project.Ieds[0].TestPoints[0]; + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, point.LiveBindingState); + Assert.NotEqual("Signal missing", point.LiveBindingText); + Assert.Contains("no absence conclusion", point.LiveBindingReason, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -150,6 +173,232 @@ public void MissingWorkspaceDevice_IsExplicitlyReported() Assert.Contains("load or connect", project.Ieds[0].TestPoints[0].LiveBindingReason.ToLowerInvariant()); } + [Fact] + public void EngineAbsent_IsTheOnlyPresentationThatMapsToSignalNotFound() + { + var absent = EnginePoint(Iec61850DesignLiveStatus.Absent); + + Assert.Equal( + IoTestLiveBindingState.SignalNotFound, + IoTestReconciliationPresentation.FromEnginePoint(absent).State); + Assert.True(IoTestReconciliationPresentation.FromEnginePoint(absent).IsConfirmedAbsent); + + var diagnosticStatuses = new[] + { + Iec61850DesignLiveStatus.DesignOnly, + Iec61850DesignLiveStatus.InvalidTarget, + Iec61850DesignLiveStatus.Unreadable, + Iec61850DesignLiveStatus.TransportFailure, + Iec61850DesignLiveStatus.FunctionalConstraintMismatch, + Iec61850DesignLiveStatus.TypeMismatch, + Iec61850DesignLiveStatus.Ambiguous, + Iec61850DesignLiveStatus.UnresolvedDesign, + Iec61850DesignLiveStatus.LiveOnly + }; + + foreach (var status in diagnosticStatuses) + { + var presentation = IoTestReconciliationPresentation.FromEnginePoint(EnginePoint(status)); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + } + } + + [Fact] + public void EngineRecoveredByProbe_IsPresentedAsVerifiedBinding() + { + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + EffectiveMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = Iec61850DesignLiveStatus.RecoveredByProbe, + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + ValueSummary = "true", + Message = "Exact read succeeded." + }, + Evidence = new[] { "Recovered by engine exact probe." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundExact, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Contains("RecoveredByProbe", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Canonical:", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Effective:", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Readable", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EngineRecoveredByAlternateProbe_PresentsCanonicalEffectiveAndAttempts() + { + const string canonical = "IEDLD/MMXU1$MX$TotW$mag$f"; + const string effective = "IEDLD/MMXU1$MX$TotW$instMag$f"; + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/MMXU1.TotW.mag.f", + MmsReference = canonical, + CanonicalMmsReference = canonical, + EffectiveMmsReference = effective, + FunctionalConstraint = "MX", + Status = Iec61850DesignLiveStatus.RecoveredByAlternateProbe, + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = effective, + FunctionalConstraint = "MX", + ValueSummary = "123.4", + Message = "Alternate exact read succeeded." + }, + ProbeAttempts = new Iec61850ProbeAttemptEvidence[] + { + new() + { + IsCanonical = true, + Explanation = "Canonical MMS target.", + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Absent, + MmsReference = canonical, + FunctionalConstraint = "MX", + FailureCode = 4, + Message = "object-undefined" + } + }, + new() + { + IsCanonical = false, + AlternateStrategy = Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + Explanation = "IEC 61850 measurement sibling mag.f -> instMag.f.", + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = effective, + FunctionalConstraint = "MX", + ValueSummary = "123.4", + Message = "Alternate exact read succeeded." + } + } + }, + Evidence = new[] { "Recovered by bounded engine alternate probing." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundNormalized, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Equal(effective, presentation.Reference); + Assert.Contains("RecoveredByAlternateProbe", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Canonical: {canonical}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Effective: {effective}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("MagnitudeInstantaneousSibling", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Probe attempt 2", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("engine failure code: 4", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EngineRecoveredByAlternateDiscovery_IsVerifiedWithoutProbe() + { + const string canonical = "IEDLD/MMXU1$MX$TotW$mag$f"; + const string effective = "IEDLD/MMXU1$MX$TotW$instMag$f"; + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/MMXU1.TotW.mag.f", + MmsReference = canonical, + CanonicalMmsReference = canonical, + EffectiveMmsReference = effective, + ObservedMmsReference = effective, + FunctionalConstraint = "MX", + AlternateStrategy = Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + Status = Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery, + Evidence = new[] { "Recovered from native live discovery before probing." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundNormalized, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Equal(effective, presentation.Reference); + Assert.Contains("RecoveredByAlternateDiscovery", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Canonical: {canonical}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Effective: {effective}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("MagnitudeInstantaneousSibling", presentation.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("Probe attempt", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ProbeBudgetDeferred_RemainsNotVerifiedAndNeverMissing() + { + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = Iec61850DesignLiveStatus.DesignOnly, + ProbeDeferredByBudget = true, + Evidence = new[] { "Exact verification was deferred because the bounded probe budget was exhausted." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.NotEvaluated, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Contains("probe budget", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("no absence conclusion", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CacheCold_BindingDoesNotProduceReconciliationSynchronously() + { + var project = Project("AA1C1F03R4ADD/GGIO6.Unknown.stVal"); + var device = DeviceWithModels(); + IoTestReconciliationCache.Invalidate(device); + + var before = IoTestReconciliationCache.Get(device); + _binding.Bind(project, new[] { device }); + var after = IoTestReconciliationCache.Get(device); + + Assert.False(before.IsCurrent); + Assert.False(after.IsCurrent); + Assert.Null(after.Document); + Assert.Contains("cache is not ready", after.FailureReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AsyncRefresh_PublishesReconciliationForExactModelGeneration() + { + var device = DeviceWithModels(); + IoTestReconciliationCache.Invalidate(device); + + await IoTestReconciliationCache.RefreshAsync(device); + var cached = IoTestReconciliationCache.Get(device); + + Assert.True(cached.IsCurrent); + Assert.NotNull(cached.Document); + Assert.NotNull(cached.ProducedAtUtc); + Assert.Equal(string.Empty, cached.FailureReason); + } + + private static Iec61850DesignLivePointReconciliation EnginePoint(Iec61850DesignLiveStatus status) + => new() + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + EffectiveMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = status, + Evidence = new[] { $"Engine status: {status}" } + }; + private static IoTestProject Project(string reference) { var project = new IoTestProject @@ -195,4 +444,31 @@ private static IoTestProject Project(string reference) Port = 102, Status = "Ready" }; + + private static Iec61850MonitorDevice DeviceWithModels() + { + var designModel = new LiveIedModelDiscoveryDocument + { + IedName = "AA1C1F03R4" + }; + var liveModel = new LiveIedModelDiscoveryDocument + { + IedName = "AA1C1F03R4" + }; + + return new Iec61850MonitorDevice + { + Name = "AA1C1F03R4", + SclIedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + Port = 102, + Status = "Ready", + SclWorkspace = new SclIedWorkspace + { + IedName = "AA1C1F03R4", + DesignModel = designModel + }, + LiveDiscoveryModel = liveModel + }; + } } diff --git a/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs new file mode 100644 index 00000000..d1cbade6 --- /dev/null +++ b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs @@ -0,0 +1,193 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeIec61850ConnectedReconciliationTests +{ + [Fact] + public async Task DisconnectedNativeOwner_ReturnsEngineTransportFailure_NotAbsent() + { + await using var client = new NativeIec61850Client(); + var design = BuildModel(new TestAttribute( + "IEDLD0/GGIO1.Ind1.stVal", + "IEDLD0/GGIO1$ST$Ind1$stVal", + "ST", + "BOOLEAN", + string.Empty)); + + var result = await client.ReconcileDesignLiveAsync( + design, + EmptyLive(), + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + ProbeKnownAlternateReferences = false, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.TransportFailure, point.Status); + Assert.Equal(Iec61850ExactProbeStatus.TransportFailure, point.Probe?.Status); + Assert.NotEqual(Iec61850DesignLiveStatus.Absent, point.Status); + Assert.Equal(0, result.AbsentCount); + } + + [Fact] + public async Task EndpointResolver_UsesExistingNativeOwner_WithoutCreatingAnotherSession() + { + const string ipAddress = "203.0.113.77"; + const int port = 65102; + await using var client = new NativeIec61850Client(); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + // NativeIec61850Client records the endpoint before entering the cancellable + // association operation. An already-cancelled token therefore registers the + // session owner deterministically without performing TCP/MMS network I/O. + await Assert.ThrowsAnyAsync( + () => client.ConnectAsync(ipAddress, port, cancelled.Token)); + + var design = BuildModel(new TestAttribute( + "IEDLD0/GGIO1.Ind1.stVal", + "IEDLD0/GGIO1$ST$Ind1$stVal", + "ST", + "BOOLEAN", + string.Empty)); + + var result = await NativeIec61850Client.ReconcileConnectedAsync( + ipAddress, + port, + design, + EmptyLive(), + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + ProbeKnownAlternateReferences = false, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.TransportFailure, point.Status); + Assert.Equal(Iec61850ExactProbeStatus.TransportFailure, point.Probe?.Status); + Assert.Equal(0, result.AbsentCount); + } + + [Fact] + public async Task AlternateDiscovery_ThroughNativeOwner_RecoversWithoutNetworkProbe() + { + const string canonical = "IEDLD0/MMXU1$MX$TotW$mag$f"; + const string alternate = "IEDLD0/MMXU1$MX$TotW$instMag$f"; + await using var client = new NativeIec61850Client(); + var design = BuildModel(new TestAttribute( + "IEDLD0/MMXU1.TotW.mag.f", + canonical, + "MX", + "FLOAT32", + string.Empty)); + var observed = BuildModel( + new TestAttribute( + "IEDLD0/MMXU1.TotW.instMag.f", + alternate, + "MX", + "FLOAT32", + "floating-point"), + "LiveMmsDiscovery"); + + var result = await client.ReconcileDesignLiveAsync( + design, + observed, + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery, point.Status); + Assert.Equal(canonical, point.CanonicalMmsReference); + Assert.Equal(alternate, point.EffectiveMmsReference); + Assert.Equal(alternate, point.ObservedMmsReference); + Assert.Equal( + Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + point.AlternateStrategy); + Assert.Empty(point.ProbeAttempts); + Assert.Null(point.Probe); + Assert.Equal(0, result.AbsentCount); + Assert.Equal(0, result.LiveOnlyCount); + } + + private static LiveIedModelDiscoveryDocument BuildModel( + TestAttribute attribute, + string source = "SclWorkspace") + => BuildModel(new[] { attribute }, source); + + private static LiveIedModelDiscoveryDocument BuildModel( + IReadOnlyCollection attributes, + string source) + { + var models = attributes.Select((attribute, index) => + { + var slash = attribute.MmsReference.IndexOf('/'); + var item = attribute.MmsReference[(slash + 1)..]; + var logicalNode = item.Split('$', StringSplitOptions.RemoveEmptyEntries)[0]; + var domain = attribute.MmsReference[..slash]; + return new + { + Domain = domain, + LogicalNode = logicalNode, + DataObject = new LiveIedDataObjectModel + { + Reference = $"{domain}/{logicalNode}.DO{index + 1}", + Name = $"DO{index + 1}", + InferredCdc = "MV", + Attributes = new[] + { + new LiveIedDataAttributeModel + { + ObjectReference = attribute.ObjectReference, + AttributePath = attribute.ObjectReference[(attribute.ObjectReference.LastIndexOf('.') + 1)..], + FunctionalConstraint = attribute.FunctionalConstraint, + MmsReference = attribute.MmsReference, + MmsItemName = item, + SclBType = attribute.SclBType, + MmsType = attribute.MmsType, + Source = source + } + } + } + }; + }).ToArray(); + + return new LiveIedModelDiscoveryDocument + { + Source = source, + IedName = "IED", + LogicalDevices = models + .GroupBy(model => model.Domain, StringComparer.OrdinalIgnoreCase) + .Select(domain => new LiveIedLogicalDeviceModel + { + MmsDomain = domain.Key, + LogicalNodes = domain + .GroupBy(model => model.LogicalNode, StringComparer.OrdinalIgnoreCase) + .Select(node => new LiveIedLogicalNodeModel + { + Name = node.Key, + DataObjects = node.Select(model => model.DataObject).ToArray() + }) + .ToArray() + }) + .ToArray() + }; + } + + private static LiveIedModelDiscoveryDocument EmptyLive() + => new() { Source = "LiveMmsDiscovery", IedName = "IED" }; + + private sealed record TestAttribute( + string ObjectReference, + string MmsReference, + string FunctionalConstraint, + string SclBType, + string MmsType); +}