diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs index 9a22b81fe..2ca060789 100644 --- a/IoListTestingWindow.ClockSyncUx.cs +++ b/IoListTestingWindow.ClockSyncUx.cs @@ -7,10 +7,9 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow { - private CheckBox? _clockSyncCheckBox; + private TextBlock? _clockSyncGlobalStatusText; private TextBlock? _clockSyncEvidenceText; private MainWindow? _clockSyncSnapshotOwner; - private bool _clockSyncCheckBoxRefreshing; protected override void OnInitialized(EventArgs e) { @@ -21,9 +20,8 @@ protected override void OnInitialized(EventArgs e) private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) { - if (_clockSyncCheckBox != null) + if (_clockSyncGlobalStatusText != null) { - RefreshClockSyncCheckBox(); AttachClockSyncSnapshotOwner(); return; } @@ -35,18 +33,17 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) if (previewIndex < 0) return; - var checkBox = new CheckBox + var globalStatus = new TextBlock { - Name = "ClockSyncEnabledCheckBox", - Content = "Clock Sync", + Name = "GlobalSntpStatusTextBlock", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), Padding = new Thickness(2, 0, 2, 0), FontSize = 11.2, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray, - Focusable = false, - ToolTip = "SNTP laptop → IED. Checked: ARSAS serves laptop time using normal UDP/123 when available, with an Npcap RAW fallback when Windows already owns UDP/123. Unchecked: ARSAS stops only its Clock Sync service. IEC 61850 remains unaffected." + Text = "Global SNTP", + ToolTip = "Global SNTP is controlled from the ARSAS header. It is not owned by FAT and continues running when the FAT window closes while the global server toggle remains enabled." }; var evidence = new TextBlock @@ -57,15 +54,12 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) FontSize = 10.4, FontWeight = FontWeights.Medium, Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray, - Text = "Clock: waiting" + Text = "SNTP: waiting" }; - _clockSyncCheckBox = checkBox; + _clockSyncGlobalStatusText = globalStatus; _clockSyncEvidenceText = evidence; - RefreshClockSyncCheckBox(); - checkBox.Checked += ClockSyncCheckBox_Changed; - checkBox.Unchecked += ClockSyncCheckBox_Changed; - actionPanel.Children.Insert(previewIndex + 1, checkBox); + actionPanel.Children.Insert(previewIndex + 1, globalStatus); actionPanel.Children.Insert(previewIndex + 2, evidence); AttachClockSyncSnapshotOwner(); } @@ -100,28 +94,40 @@ private void ClockSyncSnapshotChanged(SntpClockServiceSnapshot snapshot) private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) { - if (_clockSyncEvidenceText == null) + if (_clockSyncEvidenceText == null || _clockSyncGlobalStatusText == null) return; + var enabled = _clockSyncSnapshotOwner?.IsClockSyncEnabled == true; var transport = snapshot.TransportMode switch { SntpClockTransportMode.NpcapRaw => "RAW", SntpClockTransportMode.UdpSocket => "UDP", _ => "—" }; - - _clockSyncEvidenceText.Text = snapshot.State switch - { - SntpClockServiceState.Serving => - $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", - SntpClockServiceState.Starting => "Clock: starting…", - SntpClockServiceState.Stopped => "Clock: off", - SntpClockServiceState.PortUnavailable => "Clock: unavailable", - SntpClockServiceState.Faulted => "Clock: fault", - _ => $"Clock: {snapshot.State}" - }; - - _clockSyncEvidenceText.ToolTip = BuildClockSyncEvidenceToolTip(snapshot, transport); + var localAddress = snapshot.Binding?.LocalAddress.ToString(); + + _clockSyncGlobalStatusText.Text = !enabled + ? "Global SNTP · Off" + : snapshot.State == SntpClockServiceState.Serving + ? $"Global SNTP · {localAddress ?? "Active"}" + : "Global SNTP · Enabled"; + + _clockSyncEvidenceText.Text = !enabled + ? "SNTP: off" + : snapshot.State switch + { + SntpClockServiceState.Serving => + $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", + SntpClockServiceState.Starting => "SNTP: starting…", + SntpClockServiceState.Stopped => "SNTP: waiting for connected IED", + SntpClockServiceState.PortUnavailable => "SNTP: unavailable", + SntpClockServiceState.Faulted => "SNTP: fault", + _ => $"SNTP: {snapshot.State}" + }; + + var toolTip = BuildClockSyncEvidenceToolTip(snapshot, transport, enabled); + _clockSyncGlobalStatusText.ToolTip = toolTip; + _clockSyncEvidenceText.ToolTip = toolTip; _clockSyncEvidenceText.Foreground = snapshot.State switch { SntpClockServiceState.PortUnavailable or SntpClockServiceState.Faulted => Brushes.DarkOrange, @@ -130,60 +136,28 @@ private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) }; } - private static string BuildClockSyncEvidenceToolTip(SntpClockServiceSnapshot snapshot, string transport) + private static string BuildClockSyncEvidenceToolTip( + SntpClockServiceSnapshot snapshot, + string transport, + bool enabled) { var binding = snapshot.Binding == null ? "—" : snapshot.Binding.Summary; var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; - return $"Clock Sync evidence\n" + + return $"Global ARSAS SNTP Server\n" + + $"Enabled: {enabled}\n" + $"State: {snapshot.State}\n" + $"Transport: {transport}\n" + $"Binding: {binding}\n" + $"Broadcast sent: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + $"Client request seen: {snapshot.ClientRequestCount} (last {lastRequest})\n" + $"Mode 4 reply sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + + "The global server is controlled from the ARSAS header and continues outside FAT while enabled. " + "These counters prove packet activity only. ARSAS does not claim that the relay clock is synchronized without device-side evidence.\n\n" + snapshot.Detail; } - private void RefreshClockSyncCheckBox() - { - if (_clockSyncCheckBox == null || Owner is not MainWindow mainWindow) - return; - - _clockSyncCheckBoxRefreshing = true; - try - { - _clockSyncCheckBox.IsChecked = mainWindow.IsClockSyncEnabled; - } - finally - { - _clockSyncCheckBoxRefreshing = false; - } - } - - private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e) - { - if (_clockSyncCheckBoxRefreshing || - _clockSyncCheckBox == null || - Owner is not MainWindow mainWindow) - return; - - var requested = _clockSyncCheckBox.IsChecked == true; - _clockSyncCheckBox.IsEnabled = false; - try - { - await mainWindow.SetClockSyncEnabledAsync(requested); - RefreshClockSyncCheckBox(); - RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot); - } - finally - { - _clockSyncCheckBox.IsEnabled = true; - } - } - private void ClockSyncUx_Closed(object? sender, EventArgs e) { if (_clockSyncSnapshotOwner != null) diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs new file mode 100644 index 000000000..6bb04a16a --- /dev/null +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -0,0 +1,148 @@ +using System.ComponentModel; +using System.IO; +using System.Windows; + +namespace ArIED61850Tester; + +/// +/// P0 responsiveness guard for the FAT workspace lifecycle. +/// +/// The legacy Closing handler flushed workspace persistence synchronously on the WPF +/// Dispatcher. Keep confirmation and evidence-session state transitions on the Dispatcher +/// (the session controllers publish UI-bound PropertyChanged notifications there), then move +/// only the durable workspace save to a worker thread. Native IEC 61850 teardown is handled +/// independently by the responsive runtime facade. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P0LifecycleRegistered = RegisterP0Lifecycle(); + + private bool _p0LifecycleInstalled; + private bool _p0CloseInProgress; + private bool _p0AllowClose; + + private static bool RegisterP0Lifecycle() + { + // Avoid another Window lifecycle override in this already heavily split partial class. + // Loaded is a routed event, so one class handler can install the P0 Closing handler on + // every FAT workspace instance after XAML has wired its legacy Window_Closing handler. + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0Lifecycle_Loaded)); + return true; + } + + private static void P0Lifecycle_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow window) + window.InstallP0Lifecycle(); + } + + private void InstallP0Lifecycle() + { + if (_p0LifecycleInstalled) + return; + + _p0LifecycleInstalled = true; + + // XAML wires Window_Closing during InitializeComponent. Replace only that lifecycle + // edge; all existing OnClosed cleanup remains intact. + Closing -= Window_Closing; + Closing += P0Window_Closing; + } + + private async void P0Window_Closing(object? sender, CancelEventArgs e) + { + if (_p0AllowClose) + return; + + e.Cancel = true; + if (_p0CloseInProgress) + return; + + if (IsPreparingIed) + { + var activeNames = string.Join(", ", Project.Ieds + .Where(ied => ied.IsPreparing) + .Select(ied => ied.IedName)); + MessageBox.Show( + this, + $"ARSAS is still preparing {activeNames}. You can inspect or connect other IEDs while these independent workflows run, but finish preparation before closing this workspace.", + "IED preparation in progress", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (Session.HasActiveSessions) + { + var activeCount = Session.ActiveSessionCount; + var answer = MessageBox.Show( + this, + $"{activeCount} FAT evidence session(s) are active. Returning to Engineering will stop every active IED session, seal each independent evidence journal, and save the current project progress.\n\nStop all sessions and return?", + "Stop active FAT sessions", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + } + + _p0CloseInProgress = true; + IsEnabled = false; + + try + { + if (Session.HasActiveSessions) + { + // Session.StopAll mutates controller/project state and raises UI-bound + // PropertyChanged notifications. Keep that state transition on Dispatcher; + // offloading the whole coordinator would create a cross-thread WPF defect. + var stopAll = Session.StopAll( + "Workspace closed by operator; per-IED evidence journal sealed."); + if (!stopAll.Succeeded) + { + MessageBox.Show( + this, + stopAll.Message, + "Evidence journals could not be sealed", + MessageBoxButton.OK, + MessageBoxImage.Error); + return; + } + } + + // Persistence is pure durable I/O after the session state is sealed and is the + // portion that must not occupy the WPF Dispatcher. + if (Storage != null) + await Task.Run(Storage.SaveNow); + + _p0AllowClose = true; + Close(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + var answer = MessageBox.Show( + this, + $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", + "Progress save failed", + MessageBoxButton.YesNo, + MessageBoxImage.Error, + MessageBoxResult.No); + if (answer == MessageBoxResult.Yes) + { + _p0AllowClose = true; + Close(); + } + } + finally + { + if (!_p0AllowClose) + { + _p0CloseInProgress = false; + IsEnabled = true; + } + } + } +} diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml index 892f0445d..0fcc5a0f4 100644 --- a/IpConnectWizardWindow.xaml +++ b/IpConnectWizardWindow.xaml @@ -35,9 +35,9 @@ - + Text="{Binding RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus}"/> diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index aff2da34c..af2c2a58d 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -24,18 +24,27 @@ private void InitializeClockSyncLifecycle() return; _clockSyncLifecycleAttached = true; - Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - AttachClockSyncDevice(device); + InstallGlobalSntpToggle(); + + if (_clockSyncEnabled) + { + Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + AttachClockSyncDevice(device); + } _sntpClockService.StatusChanged += ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved; _sntpClockService.ReplySent += ClockSyncService_ReplySent; Closed += ClockSyncMainWindow_Closed; + PublishGlobalSntpUiState(); } private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + if (!_clockSyncEnabled) + return; + if (e.OldItems != null) { foreach (var item in e.OldItems.OfType()) @@ -51,6 +60,9 @@ private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollection private void AttachClockSyncDevice(Iec61850MonitorDevice device) { + if (!_clockSyncEnabled) + return; + device.PropertyChanged -= ClockSyncDevice_PropertyChanged; device.PropertyChanged += ClockSyncDevice_PropertyChanged; @@ -60,16 +72,21 @@ private void AttachClockSyncDevice(Iec61850MonitorDevice device) private void ClockSyncDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName != nameof(Iec61850MonitorDevice.IsConnected) || - sender is not Iec61850MonitorDevice device || - !device.IsConnected) + if (!_clockSyncEnabled || sender is not Iec61850MonitorDevice device || !device.IsConnected) return; - ScheduleClockSyncReconcile(device); + if (e.PropertyName == nameof(Iec61850MonitorDevice.IsConnected) || + e.PropertyName == nameof(Iec61850MonitorDevice.IpAddress)) + { + ScheduleClockSyncReconcile(device); + } } private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) { + if (!_clockSyncEnabled) + return; + if (!Dispatcher.CheckAccess()) { Dispatcher.BeginInvoke(new Action(() => ScheduleClockSyncReconcile(device))); @@ -81,13 +98,19 @@ private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) { - if (!IPAddress.TryParse(device.IpAddress, out var iedAddress) || + if (!_clockSyncEnabled || !device.IsConnected || + !IPAddress.TryParse(device.IpAddress, out var iedAddress) || iedAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return; await _clockSyncIntegrationGate.WaitAsync(); try { + // Re-check after entering the integration gate so a queued connect event cannot + // restart SNTP after the operator has switched the global toggle off. + if (!_clockSyncEnabled || !device.IsConnected) + return; + await _sntpClockService.EnsureStartedAsync(iedAddress, _applicationCancellation.Token); _sntpClockService.RequestImmediateBroadcast(); } @@ -96,8 +119,8 @@ private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) } catch (Exception ex) { - AddLog("WARN", "Clock Sync", - $"{device.Name}: IEC 61850 remains connected, but ARSAS SNTP could not start: {ex.Message}"); + AddLog("WARN", "SNTP Server", + $"{device.Name}: IEC 61850 remains connected, but the global ARSAS SNTP Server could not start: {ex.Message}"); } finally { @@ -109,8 +132,9 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) { void Publish() { - // FAT telemetry must receive every evidence-counter change even when the textual - // service detail did not change. The live log remains deduplicated separately. + // Global telemetry receives every evidence-counter change even when the textual + // service detail did not change. FAT is only one passive consumer of this state. + RefreshGlobalSntpToggle(snapshot); ClockSyncSnapshotChanged?.Invoke(snapshot); var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}"; @@ -125,7 +149,7 @@ void Publish() SntpClockServiceState.Stopped => "INFO", _ => "WARN" }; - AddLog(level, "Clock Sync", snapshot.Detail); + AddLog(level, "SNTP Server", snapshot.Detail); } if (Dispatcher.CheckAccess()) @@ -146,7 +170,7 @@ void Publish() var device = Devices.FirstOrDefault(item => item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); var name = device?.Name ?? key; - AddLog("INFO", "Clock Sync", + AddLog("INFO", "SNTP Server", $"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven."); } @@ -169,7 +193,7 @@ void Publish() item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); var name = device?.Name ?? key; var transport = observation.TransportMode == SntpClockTransportMode.NpcapRaw ? "Npcap RAW" : "UDP"; - AddLog("INFO", "Clock Sync", + AddLog("INFO", "SNTP Server", $"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it."); } diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index d9ed36b99..39598e3b3 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -1,19 +1,115 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Shapes; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; public partial class MainWindow { + // Keep the existing commissioning default for compatibility, but make the state explicit + // in the global ARSAS chrome. The service is application-scoped: FAT never owns it. private bool _clockSyncEnabled = true; + private ToggleButton? _globalSntpToggle; + private Ellipse? _globalSntpStateDot; + private TextBlock? _globalSntpCaption; + private bool _globalSntpToggleRefreshing; internal bool IsClockSyncEnabled => _clockSyncEnabled; + private void InstallGlobalSntpToggle() + { + if (_globalSntpToggle != null || WorkflowNavShell.Parent is not Grid headerGrid) + return; + + var stateDot = new Ellipse + { + Width = 8, + Height = 8, + Margin = new Thickness(0, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center, + Fill = Brushes.SlateGray + }; + var caption = new TextBlock + { + Text = "SNTP Server", + FontSize = 11.3, + FontWeight = FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + content.Children.Add(stateDot); + content.Children.Add(caption); + + var toggle = new ToggleButton + { + Name = "GlobalSntpServerToggle", + MinWidth = 282, + MaxWidth = 340, + Height = 38, + Margin = new Thickness(12, 0, 0, 0), + Padding = new Thickness(12, 0, 12, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + BorderThickness = new Thickness(1), + Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)), + BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)), + Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)), + Focusable = false, + IsThreeState = false, + Content = content + }; + + toggle.Checked += GlobalSntpToggle_Changed; + toggle.Unchecked += GlobalSntpToggle_Changed; + Grid.SetColumn(toggle, 2); + headerGrid.Children.Add(toggle); + + _globalSntpToggle = toggle; + _globalSntpStateDot = stateDot; + _globalSntpCaption = caption; + RefreshGlobalSntpToggle(_sntpClockService.Snapshot); + } + + private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) + { + if (_globalSntpToggleRefreshing || _globalSntpToggle == null) + return; + + _globalSntpToggle.IsEnabled = false; + try + { + await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); + } + finally + { + _globalSntpToggle.IsEnabled = true; + } + } + internal async Task SetClockSyncEnabledAsync(bool enabled) { if (_clockSyncEnabled == enabled) + { + if (enabled) + _sntpClockService.RequestImmediateBroadcast(); + PublishGlobalSntpUiState(); return; + } _clockSyncEnabled = enabled; + PublishGlobalSntpUiState(); if (!enabled) { @@ -27,19 +123,20 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) await _sntpClockService.StopAsync(); _clockSyncObservedClients.Clear(); _clockSyncRepliedClients.Clear(); - AddLog("INFO", "Clock Sync", - "Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active."); + AddLog("INFO", "SNTP Server", + "Global SNTP Server disabled. IEC 61850 monitoring and FAT sessions remain active."); } catch (Exception ex) { - AddLog("WARN", "Clock Sync", - $"Clock Sync disable requested, but the SNTP service reported: {ex.Message}"); + AddLog("WARN", "SNTP Server", + $"Global SNTP Server disable requested, but the clock service reported: {ex.Message}"); } finally { _clockSyncIntegrationGate.Release(); } + PublishGlobalSntpUiState(); return; } @@ -48,7 +145,118 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) foreach (var device in Devices) AttachClockSyncDevice(device); - AddLog("INFO", "Clock Sync", - "Clock Sync enabled from the FAT workspace. ARSAS will serve connected IPv4 IEDs using normal UDP/123 when available and an Npcap RAW fallback when Windows already owns that port. Windows Time is never stopped or reconfigured."); + AddLog("INFO", "SNTP Server", + "Global SNTP Server enabled. It remains active independently of FAT while ARSAS is running. Connected IPv4 IEDs select the station-bus interface; ARSAS serves UDP/123 and broadcasts Mode 5 time, with Npcap RAW fallback when Windows already owns UDP/123. Windows Time is never stopped or reconfigured."); + PublishGlobalSntpUiState(); + } + + private void PublishGlobalSntpUiState() + { + var snapshot = _sntpClockService.Snapshot; + + void Publish() + { + RefreshGlobalSntpToggle(snapshot); + ClockSyncSnapshotChanged?.Invoke(snapshot); + } + + if (Dispatcher.CheckAccess()) + Publish(); + else + Dispatcher.BeginInvoke(new Action(Publish)); + } + + private void RefreshGlobalSntpToggle(SntpClockServiceSnapshot snapshot) + { + if (_globalSntpToggle == null || _globalSntpCaption == null || _globalSntpStateDot == null) + return; + + _globalSntpToggleRefreshing = true; + try + { + _globalSntpToggle.IsChecked = _clockSyncEnabled; + } + finally + { + _globalSntpToggleRefreshing = false; + } + + var localAddress = snapshot.Binding?.LocalAddress.ToString(); + var isServing = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Serving; + var isStarting = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Starting; + var isFault = _clockSyncEnabled && snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable; + + _globalSntpCaption.Text = !_clockSyncEnabled + ? "SNTP Server Off" + : isServing + ? $"SNTP Server Active: {localAddress ?? "station bus"}" + : isStarting + ? "SNTP Server Starting…" + : isFault + ? "SNTP Server Attention" + : "SNTP Server Enabled · waiting for IED"; + + if (isServing) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(236, 253, 245)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(167, 243, 208)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(4, 120, 87)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(16, 185, 129)); + } + else if (isFault) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(255, 248, 230)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(242, 210, 138)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(148, 98, 0)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(245, 158, 11)); + } + else if (_clockSyncEnabled) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(238, 244, 255)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(201, 217, 241)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(69, 100, 142)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(47, 128, 237)); + } + else + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(148, 163, 184)); + } + + _globalSntpToggle.ToolTip = BuildGlobalSntpToolTip(snapshot); + } + + private string BuildGlobalSntpToolTip(SntpClockServiceSnapshot snapshot) + { + var binding = snapshot.Binding; + var local = binding?.LocalAddress.ToString() ?? "waiting for first connected IPv4 IED"; + var broadcast = binding?.DirectedBroadcast?.ToString() ?? "—"; + var adapter = binding?.InterfaceName ?? "—"; + var transport = snapshot.TransportMode switch + { + SntpClockTransportMode.UdpSocket => "UDP/123", + SntpClockTransportMode.NpcapRaw => "Npcap RAW", + _ => "—" + }; + var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + + return $"Global ARSAS SNTP Server\n" + + $"Enabled: {_clockSyncEnabled}\n" + + $"State: {snapshot.State}\n" + + $"Local server IP: {local}\n" + + $"Station-bus adapter: {adapter}\n" + + $"Directed broadcast: {broadcast}\n" + + $"Transport: {transport}\n" + + $"Mode 5 broadcasts: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + + $"Client requests observed: {snapshot.ClientRequestCount} (last {lastRequest})\n" + + $"Mode 4 replies sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + + "When enabled, this service is global and continues outside FAT until disabled or ARSAS closes. " + + "A sent broadcast or reply proves SNTP packet activity, not that an IED accepted or locked its internal clock. " + + "Relay/event timestamps are trustworthy only after device-side synchronization evidence confirms the clock.\n\n" + + snapshot.Detail; } } diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 7ace02f5f..cc05bd11d 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -13,6 +13,11 @@ public partial class MainWindow /// Prepares one imported IO-list IED for monitoring-only FAT acquisition. Calls for /// different IEDs are independent and may overlap; the IED model itself owns the /// preparation flag, while live binding is deliberately scoped to this IED only. + /// + /// For an SCL workspace already owned by Engineering, FAT is a consumer of the + /// existing per-IED acquisition session. It must never stop/restart that session merely + /// to change evidence scope or polling cadence. Legacy workbook-only FAT remains on the + /// compatibility acquisition path until it is migrated to the same shared-session model. /// internal async Task PrepareIoTestIedForFatAsync( IoTestProject project, @@ -103,11 +108,21 @@ void ReportProgress(string message) // connection path can then perform only TCP/ACSE/MMS association; Re-scan remains // the explicit engineering action for full live discovery/comparison. var hasSclRuntimeAuthority = AttachIoFatSclRuntimeAuthority(ied, device); - - // Monitoring-only toward the process: no control commands are executed. FAT uses - // deterministic fast MMS acquisition for digital commissioning points while the - // normal engineering workspace keeps report-first behavior for ordinary intervals. - device.AllowDynamicDataSetWrites = true; + var sharedStaticDataSetAuthority = + hasSclRuntimeAuthority && IsSharedStaticDataSetAuthority(device); + var reuseSharedSclAcquisition = + hasSclRuntimeAuthority && + (_sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId) || + sharedStaticDataSetAuthority); + + // Static DataSet is an operator acquisition contract, not a FAT-local preference. + // Never turn dynamic DataSet writes back on while FAT attaches to that shared + // Engineering session. Legacy/non-shared FAT keeps its historical compatibility + // behavior for now. + if (sharedStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + else if (!reuseSharedSclAcquisition) + device.AllowDynamicDataSetWrites = true; try { @@ -186,7 +201,9 @@ void ReportProgress(string message) // pass and used to hold this SIPROTEC FAT preparation for about one minute. // Explicit Re-scan remains the design-versus-live comparison action. IoTestReconciliationCache.Invalidate(device); - ReportProgress("Using authoritative SCL workspace identity · starting live acquisition"); + ReportProgress(reuseSharedSclAcquisition + ? "Using shared Engineering SCL identity · FAT will consume the existing acquisition session" + : "Using authoritative SCL workspace identity · starting live acquisition"); } else { @@ -289,9 +306,9 @@ void ReportProgress(string message) device.DeviceId); } - // P1 acquisition scope is the safe model match set, not the operator TEST - // selection. Keep Engineering selection and FAT TEST untouched while arming - // all proven shared-workspace-selected rows for this isolated IED runtime. + // FAT evidence scope is a consumer-side projection. It must not redefine the + // SCL acquisition session owned by Engineering. For legacy/non-shared FAT this + // list is still used by the compatibility monitor path below. var acquisitionSignals = selection.Matches .Select(match => match.Signal) .Where(signal => signal.CanPublishToRuntime) @@ -317,13 +334,15 @@ void ReportProgress(string message) match.Signal.ObjectReference); } - // Time synchronization is device-level FAT evidence rather than an ON/OFF - // test point. If the live model exposes an explicit status (for example - // SIPROTEC TimeSynchrnz or MiCOM LLN0.SyncSt), arm that one extra read-only - // signal so the FAT window can capture the real IED value automatically. - IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); + // Time-sync evidence must not contaminate a strict Static DataSet selection. + // If the status point is already part of the shared session we can consume it; + // otherwise SNTP/clock evidence remains device-level and FAT must not add a + // process point that would force an acquisition restart. + if (!reuseSharedSclAcquisition) + IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); var timeSyncSignal = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device); - if (timeSyncSignal?.CanPublishToRuntime == true && + if (!reuseSharedSclAcquisition && + timeSyncSignal?.CanPublishToRuntime == true && acquisitionSignals.All(signal => !ReferenceEquals(signal, timeSyncSignal))) { acquisitionSignals.Add(timeSyncSignal); @@ -334,52 +353,88 @@ void ReportProgress(string message) RaiseWorkspaceCounts(); _ioTestLiveBindingService.BindIed(ied, Devices); - var fatPollingNotActive = device.IsMonitoring && - device.Points.Any(point => point.PollingIntervalMs > 500); - var requestedAcquisitionReferences = acquisitionSignals - .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference)) - .Where(reference => reference.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var activeAcquisitionReferences = device.Points - .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) - .Where(reference => reference.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var acquisitionScopeChanged = - !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences); - - // Each IED monitor is isolated. Refresh only this device when its acquisition - // scope or FAT polling cadence changed; another connected/monitoring IED is - // never stopped or rebound by this preparation. - if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive)) + + if (reuseSharedSclAcquisition) { - ReportProgress("Refreshing this IED's FAT acquisition · other IED monitors remain active"); - await StopDeviceMonitorAsync(device); - } + // P0 lifecycle rule: one IED owns one acquisition session. FAT is only a + // consumer. It must not call StopDeviceMonitorAsync, StartIoFatDeviceMonitorAsync, + // or alter polling cadence merely because its evidence scope differs. + if (sharedStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + + if (!device.IsMonitoring) + { + ReportProgress(sharedStaticDataSetAuthority + ? "Starting the shared Static DataSet acquisition session · FAT will subscribe to it" + : "Starting the shared Engineering acquisition session · FAT will subscribe to it"); + if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) + { + _ioTestLiveBindingService.BindIed(ied, Devices); + return IoTestSessionActionResult.Failure( + $"{ied.IedName} connected, but ARSAS could not start the shared Engineering acquisition session required by FAT."); + } + } + else + { + ReportProgress("FAT attached to the existing shared acquisition session · no monitor restart"); + AddLog( + "INFO", + "IO Testing", + $"{ied.IedName}: FAT attached as a consumer of the existing Engineering acquisition session. Session ownership, RCB state and acquisition mode were not restarted or replaced."); + } - if (!device.IsMonitoring) + if (sharedStaticDataSetAuthority) + { + AddLog( + "INFO", + device.Name, + "Static DataSet lifecycle evidence: configured RCB control-plane setup includes GI; waiting for actual InformationReport traffic. FAT will not use MMS process polling as a substitute."); + _ = ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3)); + } + } + else { - ReportProgress("Starting independent FAT live acquisition · fast MMS verification"); - // Compatibility note: the pre-P1 source contract called StartDeviceMonitorAsync - // here. P1 routes through StartIoFatDeviceMonitorAsync so acquisition scope can - // be armed without mutating/persisting the operator TEST selection. - if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals)) + var fatPollingNotActive = device.IsMonitoring && + device.Points.Any(point => point.PollingIntervalMs > 500); + var requestedAcquisitionReferences = acquisitionSignals + .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var activeAcquisitionReferences = device.Points + .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var acquisitionScopeChanged = + !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences); + + // Compatibility path for non-shared/legacy FAT only. Shared SCL workspaces + // never enter this stop/restart branch. + if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive)) { - _ioTestLiveBindingService.BindIed(ied, Devices); - return IoTestSessionActionResult.Failure( - $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); + ReportProgress("Refreshing this legacy FAT IED acquisition · other IED monitors remain active"); + await StopDeviceMonitorAsync(device); + } + + if (!device.IsMonitoring) + { + ReportProgress("Starting legacy FAT live acquisition · fast MMS verification"); + if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals)) + { + _ioTestLiveBindingService.BindIed(ied, Devices); + return IoTestSessionActionResult.Failure( + $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); + } } } - // The monitor points now exist and MMS polling has already been scheduled. - // Return control to FAT immediately: waiting here for values/report setup made - // a sub-second SCL association look like a 30-50 second connection whenever - // the UI dispatcher was busy with the first report burst. Values and report - // optimization continue through the shared Engineering runtime. + // Bind FAT rows to the already-owned per-IED monitor points. In a shared SCL + // workspace this is purely a consumer projection; it does not create another + // runtime or change the acquisition method. var binding = _ioTestLiveBindingService.BindIed(ied, Devices); var acquisition = ReadIoFatAcquisitionSummary(requestedPoints, device); - var liveCount = requestedPoints.Count(point => + var boundCount = requestedPoints.Count(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); - if (liveCount == 0) + if (boundCount == 0) { var unresolved = requestedPoints .Take(4) @@ -388,28 +443,31 @@ void ReportProgress(string message) $"{ied.IedName} is connected and monitoring, but none of the {requestedPoints.Count} requested FAT signal(s) has a unique live monitor point. Unresolved: {string.Join(", ", unresolved)}."); } - // The temporary acquisition arm has already been restored by - // StartIoFatDeviceMonitorAsync. Persist only the real operator selection. SaveSignalSelectionMemory(device); var modelText = hasSclRuntimeAuthority ? "imported SCL model" : usedSavedModel ? "saved model" : "live model"; - var acquisitionText = acquisition.PollingCount == 0 - ? $"report-backed {acquisition.ReportCount}/{liveCount}" - : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}"; + var acquisitionText = sharedStaticDataSetAuthority + ? $"static report live {acquisition.ReportCount} · pending/unavailable {acquisition.UnknownCount} · MMS polling {acquisition.PollingCount}" + : acquisition.PollingCount == 0 + ? $"report-backed {acquisition.ReportCount}/{boundCount}" + : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}"; var timeSyncText = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device) == null ? " · time-sync fallback ready" - : " · time-sync status armed"; - var unresolvedCount = requestedPoints.Count - liveCount; + : reuseSharedSclAcquisition + ? " · time-sync evidence observed from shared scope only" + : " · time-sync status armed"; + var unresolvedCount = requestedPoints.Count - boundCount; var partialText = unresolvedCount == 0 ? string.Empty : $" · {unresolvedCount} FAT row(s) waiting for safe live binding"; - var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}{timeSyncText}{partialText}"; + var sessionText = reuseSharedSclAcquisition ? " · shared acquisition session" : string.Empty; + var message = $"{ied.IedName} · {boundCount}/{requestedPoints.Count} bound · {acquisitionText}{timeSyncText}{partialText}{sessionText}"; SetStatus(message); AddLog( unresolvedCount == 0 ? "INFO" : "WARN", "IO Testing", - $"{message}. Live rows are usable immediately; unresolved rows stay visible but are excluded from the active evidence scope until a unique live point exists. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); + $"{message}. FAT is an evidence consumer; shared SCL acquisition ownership is preserved. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); ReportProgress(message); return IoTestSessionActionResult.Success(message); } @@ -434,6 +492,40 @@ void ReportProgress(string message) } } + private async Task ObserveSharedStaticReportEvidenceAsync( + Iec61850MonitorDevice device, + TimeSpan proofWindow) + { + try + { + await Task.Delay(proofWindow, _applicationCancellation.Token); + if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device)) + return; + + if (device.HasReportStream) + { + var reportPoints = device.Points.Count(point => + IsReportSource(point.SourceMode) && + !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase)); + AddLog( + "INFO", + device.Name, + $"Static DataSet report evidence: actual InformationReport traffic observed; report-backed runtime point(s)={reportPoints}. RCB/GI path is alive."); + return; + } + + AddLog( + "WARN", + device.Name, + $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. ARSAS will remain report-pending and will NOT switch process values to cyclic MMS polling. Check RptEna/ownership, GI support and whether the IED server actually emits reports for the configured DataSet."); + MarkDiagnosticAlert(); + } + catch (OperationCanceledException) + { + // Application shutdown or explicit lifecycle cancellation needs no warning. + } + } + private bool AttachIoFatSclRuntimeAuthority( IoTestIedPlan ied, Iec61850MonitorDevice device) @@ -604,9 +696,18 @@ private static IoFatAcquisitionSummary ReadIoFatAcquisitionSummary( } private static bool IsReportSource(string source) - => source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || - source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || - source.Contains("report", StringComparison.OrdinalIgnoreCase); + { + if (string.IsNullOrWhiteSpace(source) || + source.Contains("pending", StringComparison.OrdinalIgnoreCase) || + source.Contains("unavailable", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("report", StringComparison.OrdinalIgnoreCase); + } private sealed record IoFatAcquisitionSummary( int LiveCount, diff --git a/MainWindow.IoTesting.MultiIedMonitor.cs b/MainWindow.IoTesting.MultiIedMonitor.cs index c6b405911..6353fdc5a 100644 --- a/MainWindow.IoTesting.MultiIedMonitor.cs +++ b/MainWindow.IoTesting.MultiIedMonitor.cs @@ -1,4 +1,5 @@ using ArIED61850Tester.Models; +using ArIED61850Tester.Services; using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; @@ -19,6 +20,11 @@ private async Task StartIoFatDeviceMonitorAsync( ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(acquisitionSignals); + // Engineering and FAT are two views of the same SCL workspace. Remember the + // operator's explicit Static DataSet intent independently of any transient monitor + // stop/restart that FAT performs while preparing its evidence scope. + var preserveStaticDataSetAuthority = IsSharedStaticDataSetAuthority(device); + var acquisition = acquisitionSignals .Where(signal => signal.CanPublishToRuntime) .GroupBy( @@ -52,7 +58,21 @@ private async Task StartIoFatDeviceMonitorAsync( RemoveDevicePoints(device.DeviceId); device.Points.Clear(); - SetStatus($"{device.Name}: starting independent FAT live acquisition…"); + // P0 cross-mode authority guard: FAT must never silently turn an Engineering + // Static DataSet workspace into generic Hybrid/MMS acquisition. Reassert the + // protocol contract immediately before the new runtime snapshots its mode. + if (preserveStaticDataSetAuthority) + { + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + AddLog( + "INFO", + device.Name, + "FAT reuses the shared Static DataSet report-only authority; cyclic MMS process polling remains disabled."); + } + + SetStatus(preserveStaticDataSetAuthority + ? $"{device.Name}: starting shared Static DataSet FAT acquisition…" + : $"{device.Name}: starting independent FAT live acquisition…"); var points = await _runtime.StartMonitoringAsync( device, acquisition, @@ -86,6 +106,13 @@ private async Task StartIoFatDeviceMonitorAsync( foreach (var (signal, wasSelected) in originalSelection) signal.IsSelected = wasSelected; device.EndBulkSignalSelection(); + + // A temporary FAT acquisition selection must not alter the shared protocol + // authority either. This also restores AllowDynamicDataSetWrites=false if an + // older FAT preparation path set that compatibility flag while entering FAT. + if (preserveStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + device.RefreshComputed(); RaiseWorkspaceCounts(); } diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 0b602b1b7..2531c48f5 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -21,6 +21,16 @@ private enum SclSignalSelectionMode private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); + // Keep the operator's acquisition intent independently of transient runtime teardown. + // FAT and Engineering share one device/workspace, so entering FAT must never demote an + // explicitly selected Static DataSet report-only workspace into generic Hybrid/MMS. + private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = + new(StringComparer.OrdinalIgnoreCase); + + private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) + => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) || + Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); + private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount) { var dialog = new SclSignalSelectionModeWindow(iedCount) @@ -37,19 +47,20 @@ private enum SclSignalSelectionMode private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) { - // Static DataSet is a protocol-authority mode, not a request to monitor the - // whole IED and then opportunistically prefer reports. First make sure every - // ARIEC-owned DataSet member is present in the workspace, then select only - // runtime signals that carry an explicit static DataSet identity. + // Static DataSet remains the protocol authority established by the report-only + // baseline. Materialize every ARIEC-owned member first, then select exactly one + // presentation/runtime row per literal static membership. A browsed alias carrying + // DataSetReference is not enough authority and must not inflate the live plan. var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); RegisterRecoveredDataSetSignals(device, merge); + var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device); Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); device.BeginBulkSignalSelection(); try { foreach (var signal in device.Signals) - signal.IsSelected = Iec61850StaticDataSetSelectionPolicy.IsEligible(signal); + signal.IsSelected = authoritativeSignals.Contains(signal); } finally { @@ -58,13 +69,20 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) SynchronizeAllEngineeringSelectionsToFat(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); device.RefreshComputed(); AddLog( "INFO", device.Name, - $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} runtime DataSet signal(s); cyclic MMS process polling and dynamic DataSet writes are disabled for this monitoring mode."); + $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} exact runtime member row(s) from {merge.MandatoryCatalogCount} ARIEC static membership descriptor(s); cyclic MMS process polling and dynamic DataSet writes remain disabled."); + + // Make feasibility and first-report proof visible from the initial Engineering + // workflow rather than waiting until FAT is opened. The observer waits for the + // shared monitor to start and never changes acquisition method. + LogStaticDataSetReportFeasibility(device); + _ = ObserveInitialStaticReportEvidenceAsync(device); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) @@ -84,6 +102,7 @@ private void ClearSharedSignalSelection(Iec61850MonitorDevice device) private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { // Manual selection restores the normal Smart/Hybrid acquisition contract. + _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); Iec61850MonitoringModeRegistry.UseHybrid(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); diff --git a/MainWindow.StaticReportEvidence.cs b/MainWindow.StaticReportEvidence.cs new file mode 100644 index 000000000..07b618564 --- /dev/null +++ b/MainWindow.StaticReportEvidence.cs @@ -0,0 +1,132 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// Emits a protocol-feasibility inventory before monitoring starts. A configured + /// DataSet is not automatically reportable: at least one configured BRCB/URCB must + /// reference it. This diagnostic deliberately does not infer availability from + /// freeBRCB/freeURCB counts or from process-value polling. + /// + private void LogStaticDataSetReportFeasibility(Iec61850MonitorDevice device) + { + var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (model is null) + { + AddLog("WARN", device.Name, + "Static DataSet feasibility: no SCL/live design model is available; configured RCB → DataSet mapping cannot be audited."); + return; + } + + var reports = model.ReportControls.ToArray(); + foreach (var dataSet in model.DataSets) + { + var matchingReports = reports + .Where(report => SameSclReference(report.DataSetReference, dataSet.Reference)) + .ToArray(); + + if (matchingReports.Length == 0) + { + AddLog( + "WARN", + device.Name, + $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · NO CONFIGURED RCB. These members cannot become report-live until the IED/SCL provides a BRCB/URCB for this DataSet; ARSAS will not substitute cyclic MMS process polling."); + continue; + } + + var reportSummary = string.Join(", ", matchingReports.Select(report => + $"{report.Reference} ({(report.Buffered ? "BRCB" : "URCB")})")); + AddLog( + "INFO", + device.Name, + $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · configured RCB={reportSummary}."); + } + } + + /// + /// Starts a causal proof timer from the operator's initial Static DataSet selection. + /// It waits for the shared Engineering monitor to exist, labels points whose DataSet + /// has no configured RCB, then checks for actual InformationReport traffic. A successful + /// RptEna/GI write alone is intentionally not treated as report proof. + /// + private async Task ObserveInitialStaticReportEvidenceAsync(Iec61850MonitorDevice device) + { + try + { + var monitorDeadline = DateTime.UtcNow.AddSeconds(12); + while (!device.IsMonitoring && DateTime.UtcNow < monitorDeadline) + { + if (!IsSharedStaticDataSetAuthority(device)) + return; + await Task.Delay(100, _applicationCancellation.Token); + } + + if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device)) + return; + + ApplyNoConfiguredRcbPointEvidence(device); + AddLog( + "INFO", + device.Name, + "Static DataSet causal gate: shared monitor started. RCB control-plane setup may use one-shot MMS; cyclic MMS process-value polling remains forbidden. Waiting for actual InformationReport traffic after GI."); + await ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3)); + } + catch (OperationCanceledException) + { + // Application shutdown or lifecycle cancellation needs no warning. + } + } + + private void ApplyNoConfiguredRcbPointEvidence(Iec61850MonitorDevice device) + { + var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (model is null || device.Points.Count == 0) + return; + + var reportableDataSets = model.ReportControls + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .Select(report => NormalizeSclReference(report.DataSetReference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selectedSignals = device.Signals + .Where(signal => signal.IsSelected && !string.IsNullOrWhiteSpace(signal.DataSetReference)) + .GroupBy(signal => NormalizeSclReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var unavailable = 0; + foreach (var point in device.Points) + { + if (!selectedSignals.TryGetValue(NormalizeSclReference(point.IecReference), out var signal)) + continue; + + var dataSetReference = NormalizeSclReference(signal.DataSetReference); + if (dataSetReference.Length == 0 || reportableDataSets.Contains(dataSetReference)) + continue; + + point.SourceMode = "Static DataSet: no configured RCB"; + point.Status = "Unavailable / no configured RCB"; + unavailable++; + } + + if (unavailable > 0) + { + AddLog( + "WARN", + device.Name, + $"Static DataSet feasibility projected to runtime: {unavailable} point(s) belong to DataSet(s) with no configured RCB and are explicitly marked unavailable instead of generic report-pending or MMS polling."); + } + } + + private static bool SameSclReference(string? left, string? right) + => string.Equals( + NormalizeSclReference(left), + NormalizeSclReference(right), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeSclReference(string? value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace('$', '.'); +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 25d8bcfef..0b939ec05 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -83,6 +83,7 @@ private sealed class DeviceSession public string HealthProbePointKey { get; set; } = string.Empty; public int ControlCommandActive; public HybridReportPhysicalValidationTracker HybridValidation { get; } = new(); + public StaticDataSetReportProjectionAccumulator StaticReportProjection { get; } = new(); public bool StaticDataSetReportOnly { get; set; } } @@ -399,6 +400,7 @@ public async Task> StartMonitoringAsync( session.ConsecutiveHealthProbeFailures = 0; session.HealthProbePointKey = string.Empty; session.HybridValidation.Reset(null); + session.StaticReportProjection.Reset(); session.StaticDataSetReportOnly = staticDataSetReportOnly; var safePollMs = Math.Clamp(pollingIntervalMs <= 0 ? 1000 : pollingIntervalMs, 50, 600000); @@ -743,22 +745,21 @@ private async Task StartReportPlansAsync( session.ActiveReportPlanOrder.Add(plan); var coveredPoints = ResolveCoveredPoints(plan, result.CoveredReferences); - var acquisitionLabel = string.IsNullOrWhiteSpace(result.AcquisitionLabel) - ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet) - : result.AcquisitionLabel; + var acquisitionLabel = session.StaticDataSetReportOnly + ? BuildStaticDataSetAcquisitionLabel(plan) + : string.IsNullOrWhiteSpace(result.AcquisitionLabel) + ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet) + : result.AcquisitionLabel; foreach (var point in coveredPoints) { - if (RequiresExactMmsValueAuthority(point.IecReference)) - { - if (session.StaticDataSetReportOnly && session.States.TryGetValue(point.PointKey, out var unresolvedState)) - { - unresolvedState.SourceMode = "Static DataSet: report leaf unresolved"; - unresolvedState.AcquisitionLabel = unresolvedState.SourceMode; - unresolvedState.Reason = "structured report projection is not yet schema-proven; unsafe MMS fallback is disabled in Static DataSet mode"; - EmitStatusSnapshot(point, unresolvedState, "Static report leaf unresolved / no MMS fallback", "Pending"); - } + // Hybrid/manual mode retains its conservative exact-MMS authority rule. + // Static DataSet report-only mode is different: when ARIEC proves that + // the configured RCB covers the literal DataSet member, bind that point + // to the report plan and let schema-safe report projection decide whether + // an arriving value is publishable. Never convert this case into MMS. + if (!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority(point.IecReference)) continue; - } + session.PointPlanIds[point.PointKey] = plan.PlanId; if (!string.IsNullOrWhiteSpace(result.ReportControlReference)) point.ReportControlReference = result.ReportControlReference; @@ -768,8 +769,12 @@ private async Task StartReportPlansAsync( { pointState.AcquisitionLabel = acquisitionLabel; pointState.SourceMode = acquisitionLabel; - pointState.Reason = "awaiting report / MMS initial read"; - pointState.Status = "Report armed / verification active"; + pointState.Reason = session.StaticDataSetReportOnly + ? "awaiting configured static report / General Interrogation" + : "awaiting report / MMS initial read"; + pointState.Status = session.StaticDataSetReportOnly + ? "Static report armed / awaiting value" + : "Report armed / verification active"; } } @@ -954,22 +959,37 @@ private async Task> BuildReportPlansForCurrentA { IsAuthoritative = true, Authority = "ARIEC61850 hybrid acquisition", - Status = "Planner failure / polling safe", - Summary = $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.", + Status = session.StaticDataSetReportOnly + ? "Planner failure / static report unavailable" + : "Planner failure / polling safe", + Summary = session.StaticDataSetReportOnly + ? $"ARIEC report planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted and MMS process fallback remains disabled by Static DataSet mode." + : $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.", RequestedPointCount = session.Points.Count, - PollingPointKeys = session.Points.Keys.ToArray(), - PollingFallbackSignalCount = session.Points.Count, + PollingPointKeys = session.StaticDataSetReportOnly ? Array.Empty() : session.Points.Keys.ToArray(), + PollingFallbackSignalCount = session.StaticDataSetReportOnly ? 0 : session.Points.Count, + UncoveredSignalCount = session.StaticDataSetReportOnly ? session.Points.Count : 0, Warnings = [$"Hybrid planning exception: {ex.GetType().Name}: {ex.Message}"] }; } session.HybridValidation.Reset(hybrid); - Log("INFO", session.Device.Name, - $"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}"); + if (session.StaticDataSetReportOnly) + { + Log("INFO", session.Device.Name, + $"Static DataSet ARIEC authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, engineFallbackCandidates={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling."); + } + else + { + Log("INFO", session.Device.Name, + $"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}"); + } foreach (var warning in hybrid.Warnings.Take(5)) Log("WARN", session.Device.Name, warning); - return hybrid.ReportPlans; + return session.StaticDataSetReportOnly + ? hybrid.ReportPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray() + : hybrid.ReportPlans; } session.HybridValidation.Reset(null); @@ -1051,22 +1071,37 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0) RecordSuccessfulIo(session); - foreach (var update in slice.Updates) + foreach (var sourceUpdate in slice.Updates) { - var point = FindPointForReportReference(session, update.Reference); - if (point == null) - continue; + var effectiveUpdates = session.StaticDataSetReportOnly + ? session.StaticReportProjection.Project( + session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel, + session.Points.Values.ToArray(), + sourceUpdate) + : new[] { sourceUpdate }; + + foreach (var update in effectiveUpdates) + { + var point = FindPointForReportReference(session, update.Reference); + if (point == null) + continue; - // Siemens static DataSets report THD as a nested three-phase structure. - // Until its scalar fan-out is schema-proven, exact MMS reads remain the - // value authority so a report projection cannot overwrite correct values. - if (RequiresExactMmsValueAuthority(point.IecReference)) - continue; + // The old rule rejected every THD/DmdWh report value merely because + // those families once required MMS reads. ARIEC now provides exact + // semantic leaf identities, and StaticDataSetReportProjectionAccumulator + // reconstructs structured parents from schema-named leaves. Keep the + // legacy veto only when the report update itself is not schema-proven. + if (RequiresExactMmsValueAuthority(point.IecReference) && + !HasSchemaProvenReportValueAuthority(point, update)) + { + continue; + } - // A real report update proves reference coverage, but an initial GI or - // integrity image does not yet prove that dchg/qchg is working. Keep MMS - // verification active until a change report is actually observed. - session.PointPlanIds[point.PointKey] = plan.PlanId; + // A real report update proves reference coverage. In Hybrid mode an + // initial GI/integrity image still keeps MMS verification active until + // dchg/qchg is proven; Static DataSet mode publishes the schema-safe GI + // image directly because cyclic MMS process verification is disabled. + session.PointPlanIds[point.PointKey] = plan.PlanId; var state = session.States[point.PointKey]; var display = update.HasValue @@ -1084,7 +1119,8 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT { // A malformed/misaligned report is still useful as proof that the // RCB is alive, but its process value is not authoritative. Do not - // mutate state or SOE history; keep MMS verification/fallback active. + // mutate state or SOE history. Hybrid mode keeps MMS verification; + // Static DataSet mode stays report-pending without any MMS fallback. state.ReportTrafficSeen = true; state.LastReportUtc = DateTime.UtcNow; state.ReportChangeVerified = false; @@ -1096,7 +1132,9 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (rawSummary.Length > 120) rawSummary = rawSummary[..120] + "…"; Log("WARN", session.Device.Name, - $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); + session.StaticDataSetReportOnly + ? $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} The point remains report-pending; MMS process fallback is disabled." + : $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); } continue; } @@ -1168,11 +1206,16 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT reportSource, string.IsNullOrWhiteSpace(update.Reason) ? "report / reason not supplied" : update.Reason, receivedUtc, - state.ReportChangeVerified - ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})" - : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})", + session.StaticDataSetReportOnly + ? state.ReportChangeVerified + ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report verified" : $"Live / static report verified ({update.ProjectionStatus})" + : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report" : $"Live / static report ({update.ProjectionStatus})" + : state.ReportChangeVerified + ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})" + : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})", trustReportEdge: true, hasProcessValue: update.HasValue); + } } var verifiedReportPointKeys = slice.Updates @@ -2032,6 +2075,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.ActiveReportPlans.Clear(); session.ActiveReportPlanOrder.Clear(); session.PointPlanIds.Clear(); + session.StaticReportProjection.Reset(); session.PollQueue.Clear(); session.ReportStreams.Clear(); session.LastUnroutedReportCount = 0; @@ -2111,6 +2155,33 @@ private static bool RequiresExactMmsValueAuthority(string reference) normalized.Contains(".dmdwh", StringComparison.OrdinalIgnoreCase); } + private static bool HasSchemaProvenReportValueAuthority( + Iec61850MonitorPoint point, + NativeReportValueUpdate update) + { + var projectionStatus = (update.ProjectionStatus ?? string.Empty).Trim(); + if (projectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("schema-safe-report-three-phase-aggregate", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("schema-safe-report-demand-energy-aggregate", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Exact report identity is also authoritative for a scalar member. This admits + // direct and deterministic MX-pair projections only when ARIEC has named exactly + // the same IEC reference selected by the runtime; parent/child prefix coincidence + // alone is not sufficient. + if (!CanonicalDataReference(point.IecReference) + .Equals(CanonicalDataReference(update.Reference), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return projectionStatus.Length == 0 || + projectionStatus.Equals("direct", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase); + } + private static bool HasExactSemanticEdge(Iec61850MonitorPoint point, string oldValue, string newValue) { var oldText = (oldValue ?? string.Empty).Trim(); @@ -2257,6 +2328,13 @@ private static int GetVerificationPollIntervalMs( state.ReportTrafficSeen, state.ReportChangeVerified); + private static string BuildStaticDataSetAcquisitionLabel(ReportControlPlan plan) + { + if (!string.IsNullOrWhiteSpace(plan.EngineAcquisitionKind)) + return $"Static DataSet: {plan.EngineAcquisitionKind}"; + return plan.Buffered ? "Static DataSet: StaticBrcb" : "Static DataSet: StaticUrcb"; + } + private static string BuildPlanAcquisitionLabel(ReportControlPlan plan, bool dynamicDataSet) { var reference = (plan.ReportControlReference ?? string.Empty).Trim().Replace('$', '.'); diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs new file mode 100644 index 000000000..638440473 --- /dev/null +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -0,0 +1,87 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Builds the exact Static DataSet selection used by the report-only workflow. +/// +/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several +/// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or +/// structured measurement descendants). Static mode must select one presentation row +/// for each engine-authoritative DataSet membership, preserving the literal member +/// identity that appears in SCL/ARIEC. +/// +public static class Iec61850StaticDataSetAuthoritySelection +{ + public static IReadOnlySet Build(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (model is null) + return new HashSet(ReferenceEqualityComparer.Instance); + + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var selected = new HashSet(ReferenceEqualityComparer.Instance); + var signals = device.Signals.ToArray(); + + foreach (var descriptor in mandatory) + { + var membership = descriptor.DataSetMemberships + .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.MemberIndex) + .FirstOrDefault(); + if (membership is null) + continue; + + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference)) + continue; + + var candidates = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .ToArray(); + if (candidates.Length == 0) + continue; + + // Prefer the runtime row already bound to ARIEC's resolved primary value. + // For an unresolved structured member, the inventory-created exact static row + // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. + var chosen = candidates + .OrderByDescending(signal => + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) + .ThenByDescending(signal => + (signal.Source ?? string.Empty).Contains( + "mandatory static DataSet member", + StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) + .First(); + + selected.Add(chosen); + } + + return selected; + } + + private static bool LiteralEquals(string? left, string? right) + => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeLiteral(string? value) + => (value ?? string.Empty).Trim(); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index bcf9a5de5..5c9750701 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -45,6 +45,17 @@ public async Task BuildHybridReportPlansAsync( ArgumentNullException.ThrowIfNull(points); cancellationToken.ThrowIfCancellationRequested(); + // Static DataSet is deliberately deterministic: exact configured SCL RCB + + // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not + // send Static mode through adaptive Hybrid availability/capability policy. + if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)) + { + return await BuildStaticDataSetReportPlansAsync( + device, + points, + cancellationToken).ConfigureAwait(false); + } + _authoritativeHybridSubscriptions.Clear(); ResetSemanticReportProjectionContext(); @@ -356,6 +367,12 @@ public async Task StartHybridReportMonitorAsync( ArgumentNullException.ThrowIfNull(plan); cancellationToken.ThrowIfCancellationRequested(); + // Deterministic Static plans are executed directly. Hybrid revalidation can + // otherwise discard a valid configured RCB merely because reservation metadata + // is incomplete, which is exactly the field regression this path removes. + if (_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)) + return await StartStaticDataSetReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); + if (!plan.IsEngineAuthoritative) { return new NativeReportMonitorStartResult diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs new file mode 100644 index 000000000..68cdcd9bb --- /dev/null +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -0,0 +1,398 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Deterministic acquisition path used only by Static DataSet report-only mode. +/// +/// Static mode already has complete configuration authority in the opened/live SCL model: +/// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts +/// through the adaptive Hybrid acquisition planner. The live association is used only to +/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's +/// persistent monitor installs the InformationReport receiver, enables RptEna and requests +/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here. +/// +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _deterministicStaticSubscriptions = + new(StringComparer.OrdinalIgnoreCase); + + public async Task BuildStaticDataSetReportPlansAsync( + Iec61850MonitorDevice device, + IReadOnlyCollection points, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(points); + cancellationToken.ThrowIfCancellationRequested(); + + _deterministicStaticSubscriptions.Clear(); + ResetSemanticReportProjectionContext(); + + var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (model is null) + { + return StaticPlanningUnavailable( + points, + "Static DataSet report-only requires an opened/live SCL model with exact DataSet and RCB configuration."); + } + + if (!_session.IsMmsInitiated) + { + return StaticPlanningUnavailable( + points, + $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}."); + } + + SetSemanticReportProjectionAuthority(model); + + // Fresh report discovery is verification, not permission policy. In particular we + // deliberately do NOT classify a configured BRCB through the Hybrid availability + // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit + // enough reservation metadata for that adaptive gate to call the RCB 'Available'. + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return StaticPlanningUnavailable( + points, + string.IsNullOrWhiteSpace(LastErrorMessage) + ? "Fresh report discovery was unavailable." + : LastErrorMessage); + } + + var reportPlans = new List(); + var warnings = new List(); + var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + var selectedByDataSet = points + .Where(point => !string.IsNullOrWhiteSpace(point.DataSetReference)) + .GroupBy(point => NormalizeStaticReference(point.DataSetReference), StringComparer.OrdinalIgnoreCase) + .Where(group => !string.IsNullOrWhiteSpace(group.Key)) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var dataSetGroup in selectedByDataSet) + { + cancellationToken.ThrowIfCancellationRequested(); + + var configuredReports = model.ReportControls + .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) + .OrderByDescending(report => report.Buffered) + .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (configuredReports.Length == 0) + { + warnings.Add( + $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + continue; + } + + if (configuredReports.Length > 1) + { + warnings.Add( + $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." ); + } + + var configured = configuredReports[0]; + var liveCandidates = discovery.ReportInventory.ReportControls + .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference)) + .ToArray(); + + if (liveCandidates.Length != 1) + { + warnings.Add( + $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values."); + continue; + } + + var liveRcb = CloneReportControlForPlanning(liveCandidates[0]); + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + + if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && + !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) + { + warnings.Add( + $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); + continue; + } + + // Missing live DatSet text is not treated as a reason to discard correct SCL + // configuration. The exact DataSet directory below is still required and is the + // ordered mapping authority for InformationReport values. + if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) + liveRcb.DataSetReference = dataSetReference; + + var directories = await RunMmsOperationAsync( + () => _session.GetDataSetDirectoriesAsync( + new[] { dataSetReference }, + discovery.IedDirectory, + cancellationToken), + cancellationToken).ConfigureAwait(false); + var directory = directories.SingleOrDefault(result => + result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference)); + + if (directory is null || directory.Members.Count == 0) + { + var detail = directories.FirstOrDefault()?.Message ?? "no directory response"; + warnings.Add( + $"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled."); + continue; + } + + var bindings = dataSetGroup + .GroupBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(point => point.IecReference, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (bindings.Count == 0) + continue; + + var plan = new ReportControlPlan + { + RelayId = device.DeviceId, + RelayName = device.Name, + RelayIpAddress = device.IpAddress, + IedName = device.Name, + ReportControlReference = configured.Reference, + DataSetReference = dataSetReference, + Mode = "Static DataSet • deterministic configured RCB", + AllowDynamicDataSetWrites = false, + Buffered = configured.Buffered, + ReportId = liveRcb.ReportId, + IntegrityPeriodMs = ParseStaticInteger(liveRcb.IntegrityPeriodMs), + TriggerOptions = liveRcb.TriggerOptions, + OptionalFields = liveRcb.OptionalFields, + Status = "Deterministic static report planned", + IsEngineAuthoritative = true, + EngineAcquisitionKind = configured.Buffered ? "StaticBrcb" : "StaticUrcb", + Bindings = bindings + }; + + var subscriptionWarnings = new List(); + if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference)) + { + subscriptionWarnings.Add( + "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); + } + + var subscription = new ArMms.MmsReportSubscriptionPlan + { + Mode = ArMms.MmsReportSubscriptionPlanMode.StaticDataSet, + Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = liveRcb, + DataSetReference = dataSetReference, + Members = directory.Members, + DynamicPoints = Array.Empty(), + Steps = new[] + { + $"Verify exact configured RCB {configured.Reference} exists on the live association.", + $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", + "Install InformationReport receiver before enabling the RCB.", + "Write RptEna=true, then request GI=true.", + "Map report values by ordered DataSet member index; never substitute cyclic MMS process reads." + }, + Warnings = subscriptionWarnings + }; + + _deterministicStaticSubscriptions[plan.PlanId] = subscription; + reportPlans.Add(plan); + foreach (var binding in bindings) + coveredPointKeys.Add(binding.PointKey); + } + + var uncovered = points + .Where(point => !coveredPointKeys.Contains(point.PointKey)) + .Select(point => point.PointKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var staticBrcb = reportPlans.Where(plan => plan.Buffered).Sum(plan => plan.BindingCount); + var staticUrcb = reportPlans.Where(plan => !plan.Buffered).Sum(plan => plan.BindingCount); + + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "Deterministic Static DataSet configured-RCB path", + Status = reportPlans.Count > 0 ? "StaticReportReady" : "StaticReportingUnavailable", + Summary = reportPlans.Count > 0 + ? $"Deterministic Static DataSet path prepared {reportPlans.Count} configured RCB plan(s), covering {coveredPointKeys.Count}/{points.Count} selected point(s). Hybrid planning, dynamic DataSet writes and cyclic MMS process polling were bypassed." + : $"No configured Static DataSet RCB could be armed safely for {points.Count} selected point(s). Hybrid planning and cyclic MMS process polling were not used.", + ReportPlans = reportPlans, + PollingPointKeys = Array.Empty(), + UncoveredPointKeys = uncovered, + UnmappedPointKeys = Array.Empty(), + PointAttemptEvidence = Array.Empty(), + Warnings = warnings, + RequestedPointCount = points.Count, + CatalogMappedPointCount = points.Count, + StaticBrcbSignalCount = staticBrcb, + StaticUrcbSignalCount = staticUrcb, + DynamicBrcbSignalCount = 0, + DynamicUrcbSignalCount = 0, + PollingFallbackSignalCount = 0, + UncoveredSignalCount = uncovered.Length + }; + } + + public async Task StartStaticDataSetReportMonitorAsync( + ReportControlPlan plan, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + cancellationToken.ThrowIfCancellationRequested(); + + if (!_deterministicStaticSubscriptions.TryGetValue(plan.PlanId, out var subscription)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = "Deterministic Static DataSet subscription evidence is missing. RCB was not armed; MMS process polling remains disabled.", + FailureReason = "StaticSubscriptionEvidenceMissing" + }; + } + + if (_reportMonitorSessions.ContainsKey(plan.PlanId)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet monitor already active for {plan.DisplayReference}.", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}", + CoveredReferences = _reportMonitorCoverage.TryGetValue(plan.PlanId, out var existingCoverage) + ? existingCoverage + : Array.Empty() + }; + } + + if (!_session.IsMmsInitiated) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet monitor requires an initiated MMS association. Current state: {_session.State}.", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + FailureReason = "TransportUnavailable" + }; + } + + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = string.IsNullOrWhiteSpace(LastErrorMessage) ? "Fresh report discovery unavailable." : LastErrorMessage, + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + FailureReason = "FreshReportDiscoveryUnavailable" + }; + } + + var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members); + var attempt = await RunMmsOperationAsync( + () => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync( + subscription, + triggerGeneralInterrogation: true, + deleteDynamicDataSetOnStop: false, + discovery.IedDirectory, + cancellationToken), + cancellationToken).ConfigureAwait(false); + var start = attempt.StartResult; + var warnings = start.Warnings + .Concat(subscription.Warnings) + .Concat(attempt.CleanupWarnings) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!attempt.IsSuccess || start.Session is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet activation failed for {plan.DisplayReference}: {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + FailureReason = attempt.FailureReason.ToString(), + CleanupAttempted = attempt.CleanupAttempted, + CleanupSucceeded = attempt.CleanupSucceeded, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) + plan.ReportControlReference = start.Session.ReportControl.Reference; + if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) + plan.DataSetReference = start.Session.Plan.DataSetReference; + + _reportMonitorSessions[plan.PlanId] = start.Session; + _reportMonitorCoverage[plan.PlanId] = coveredReferences; + + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet {plan.EngineAcquisitionKind} active. {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}", + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + private static NativeHybridReportPlanningResult StaticPlanningUnavailable( + IReadOnlyCollection points, + string reason) + => new() + { + IsAuthoritative = true, + Authority = "Deterministic Static DataSet configured-RCB path", + Status = "StaticReportingUnavailable", + Summary = reason + " Cyclic MMS process polling was not enabled.", + RequestedPointCount = points.Count, + CatalogMappedPointCount = points.Count, + PollingPointKeys = Array.Empty(), + UncoveredPointKeys = points.Select(point => point.PointKey).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + UnmappedPointKeys = Array.Empty(), + PointAttemptEvidence = Array.Empty(), + Warnings = new[] { reason }, + PollingFallbackSignalCount = 0, + UncoveredSignalCount = points.Count + }; + + private static bool SameStaticReference(string? left, string? right) + => string.Equals( + NormalizeStaticReference(left), + NormalizeStaticReference(right), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeStaticReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static int ParseStaticInteger(string? value) + => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; +} diff --git a/Services/StaticDataSetReportProjectionAccumulator.cs b/Services/StaticDataSetReportProjectionAccumulator.cs new file mode 100644 index 000000000..ac4e82d50 --- /dev/null +++ b/Services/StaticDataSetReportProjectionAccumulator.cs @@ -0,0 +1,399 @@ +using System.Globalization; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Reconstructs a single Static DataSet member value from schema-proven report leaves. +/// +/// ARIEC deliberately expands structured report members such as MHAI.ThdA/ThdPPV into +/// exact scalar descendants. Static DataSet mode, however, presents one row per literal +/// FCDA/FCD membership (the same boundary an IED engineering browser shows). This +/// accumulator joins only the exact leaves named by the authoritative SCL/live schema and +/// publishes a synthetic parent update after every required leaf has been observed. +/// No MMS read, positional child guessing, prefix-only phase choice, or fuzzy DataSet +/// mapping is performed here. +/// +public sealed class StaticDataSetReportProjectionAccumulator +{ + private sealed record LeafState( + string Reference, + string Label, + string Value, + string Quality, + string Timestamp, + string Reason, + DateTimeOffset UpdatedAt, + NativeReportValueUpdate Source); + + private sealed record CompanionMetadata( + string Quality, + string Timestamp, + DateTimeOffset UpdatedAt); + + private readonly Dictionary> _leavesByParent = + new(StringComparer.OrdinalIgnoreCase); + + // IEC 61850 report projection may publish q/t companion evidence before the scalar + // value leaf produced by semantic expansion. Keep that report evidence session-local + // and join it back to the later value update. This is deliberately not an MMS read or + // a synthetic "good" quality default: only q/t already supplied by the report can be + // carried forward. + private readonly Dictionary _metadataByReference = + new(StringComparer.OrdinalIgnoreCase); + + public void Reset() + { + _leavesByParent.Clear(); + _metadataByReference.Clear(); + } + + /// + /// Returns the updates that should be consumed by the Static DataSet runtime for one + /// engine report update. Ordinary scalar members pass through unchanged. A semantic + /// descendant belonging to a selected structured parent is withheld until the parent + /// can be reconstructed from all exact schema-named leaves. If the descendant itself + /// is also an independently selected DataSet member, its original update is preserved. + /// + public IReadOnlyList Project( + LiveIedModelDiscoveryDocument? authorityModel, + IReadOnlyCollection monitoredPoints, + NativeReportValueUpdate update) + { + ArgumentNullException.ThrowIfNull(monitoredPoints); + ArgumentNullException.ThrowIfNull(update); + + if (string.IsNullOrWhiteSpace(update.Reference)) + return new[] { update }; + + var updateReference = NormalizeReference(update.Reference); + RememberCompanionMetadata(updateReference, update); + var effectiveUpdate = EnrichFromCompanionMetadata(updateReference, update); + + if (!effectiveUpdate.HasValue) + return new[] { effectiveUpdate }; + + var exactSelected = monitoredPoints.Any(point => + ReferencesEqual(point.IecReference, effectiveUpdate.Reference)); + + var aggregateParents = monitoredPoints + .Where(point => IsAggregate(point.IecReference)) + .Where(point => IsDescendant(updateReference, NormalizeReference(point.IecReference))) + .ToArray(); + + // A semantic child may not be assigned to an aggregate unless its parent is unique. + // In an ambiguous model, preserve only an independently selected exact child. + if (aggregateParents.Length != 1) + return exactSelected ? new[] { effectiveUpdate } : aggregateParents.Length == 0 ? new[] { effectiveUpdate } : Array.Empty(); + + var parent = aggregateParents[0]; + if (!SchemaSafeAggregateProjectionService.TryBuildReadPlan( + authorityModel, + parent.IecReference, + out var plan, + out _)) + { + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); + } + + var expectedLeaf = plan.Leaves.FirstOrDefault(leaf => + ReferencesEqual(leaf.Reference, effectiveUpdate.Reference)); + if (expectedLeaf is null) + { + // Ignore non-authoritative siblings such as instCVal when cVal is the schema- + // preferred engineering value. Keep it only when it is a separate selected row. + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); + } + + var parentKey = NormalizeReference(parent.IecReference); + if (!_leavesByParent.TryGetValue(parentKey, out var leaves)) + { + leaves = new Dictionary(StringComparer.OrdinalIgnoreCase); + _leavesByParent[parentKey] = leaves; + } + + leaves[NormalizeReference(expectedLeaf.Reference)] = new LeafState( + expectedLeaf.Reference, + expectedLeaf.Label, + effectiveUpdate.Value, + effectiveUpdate.HasQuality ? effectiveUpdate.Quality : string.Empty, + effectiveUpdate.HasTimestamp ? effectiveUpdate.Timestamp : string.Empty, + effectiveUpdate.Reason, + effectiveUpdate.UpdatedAt, + effectiveUpdate); + + var projected = BuildParentUpdate(parent, plan, leaves); + if (projected is null) + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); + + return exactSelected + ? new[] { effectiveUpdate, projected } + : new[] { projected }; + } + + private void RememberCompanionMetadata(string updateReference, NativeReportValueUpdate update) + { + if ((!update.HasQuality || !IsUsefulMetadata(update.Quality)) && + (!update.HasTimestamp || !IsUsefulMetadata(update.Timestamp))) + { + return; + } + + foreach (var key in MetadataKeys(updateReference)) + { + _metadataByReference.TryGetValue(key, out var previous); + var quality = update.HasQuality && IsUsefulMetadata(update.Quality) + ? update.Quality.Trim() + : previous?.Quality ?? string.Empty; + var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp) + ? update.Timestamp.Trim() + : previous?.Timestamp ?? string.Empty; + var updatedAt = update.UpdatedAt == default + ? previous?.UpdatedAt ?? default + : update.UpdatedAt; + + _metadataByReference[key] = new CompanionMetadata(quality, timestamp, updatedAt); + } + } + + private NativeReportValueUpdate EnrichFromCompanionMetadata( + string updateReference, + NativeReportValueUpdate update) + { + if (!update.HasValue || (update.HasQuality && update.HasTimestamp)) + return update; + + CompanionMetadata? metadata = null; + foreach (var key in MetadataKeys(updateReference)) + { + if (_metadataByReference.TryGetValue(key, out metadata)) + break; + } + + if (metadata is null) + return update; + + var quality = update.HasQuality && IsUsefulMetadata(update.Quality) + ? update.Quality + : metadata.Quality; + var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp) + ? update.Timestamp + : metadata.Timestamp; + var hasQuality = update.HasQuality || IsUsefulMetadata(quality); + var hasTimestamp = update.HasTimestamp || IsUsefulMetadata(timestamp); + + if (hasQuality == update.HasQuality && + hasTimestamp == update.HasTimestamp && + string.Equals(quality, update.Quality, StringComparison.Ordinal) && + string.Equals(timestamp, update.Timestamp, StringComparison.Ordinal)) + { + return update; + } + + return new NativeReportValueUpdate + { + Reference = update.Reference, + FunctionalConstraint = update.FunctionalConstraint, + Value = update.Value, + Quality = quality, + Timestamp = timestamp, + Reason = update.Reason, + Source = update.Source, + ProjectionStatus = update.ProjectionStatus, + HasValue = update.HasValue, + HasQuality = hasQuality, + HasTimestamp = hasTimestamp, + ReportControlReference = update.ReportControlReference, + ReportId = update.ReportId, + DataSetReference = update.DataSetReference, + SequenceNumber = update.SequenceNumber, + ConfRev = update.ConfRev, + ReportTimestamp = update.ReportTimestamp, + UpdatedAt = update.UpdatedAt + }; + } + + private static IEnumerable MetadataKeys(string normalizedReference) + { + if (string.IsNullOrWhiteSpace(normalizedReference)) + yield break; + + yield return normalizedReference; + + var scope = MetadataScope(normalizedReference); + if (!scope.Equals(normalizedReference, StringComparison.OrdinalIgnoreCase)) + yield return scope; + } + + private static string MetadataScope(string normalizedReference) + { + var suffixes = new[] + { + ".instcval.mag.f", + ".cval.mag.f", + ".instmag.f", + ".mag.f", + ".q", + ".t" + }; + + foreach (var suffix in suffixes) + { + if (normalizedReference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return normalizedReference[..^suffix.Length]; + } + + return normalizedReference; + } + + private static NativeReportValueUpdate? BuildParentUpdate( + Iec61850MonitorPoint parent, + SchemaSafeAggregateProjectionService.ReadPlan plan, + IReadOnlyDictionary leaves) + { + if (plan.Kind.Equals("ThreePhaseThd", StringComparison.OrdinalIgnoreCase)) + { + var ordered = new List(plan.Leaves.Count); + foreach (var expected in plan.Leaves) + { + if (!leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) || + !TryParseNumeric(leaf.Value, out _)) + { + return null; + } + ordered.Add(leaf); + } + + var display = string.Join(", ", ordered.Select(leaf => + { + TryParseNumeric(leaf.Value, out var numeric); + return $"{leaf.Label}={numeric.ToString("0.######", CultureInfo.InvariantCulture)}"; + })); + + return Synthesize( + parent.IecReference, + display, + "schema-safe-report-three-phase-aggregate", + ordered); + } + + if (plan.Kind.Equals("DemandEnergy", StringComparison.OrdinalIgnoreCase)) + { + var expected = plan.Leaves.SingleOrDefault(); + if (expected is null || + !leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) || + !TryParseNumeric(leaf.Value, out var numeric)) + { + return null; + } + + var display = numeric.ToString("0.######", CultureInfo.InvariantCulture); + return Synthesize( + parent.IecReference, + display, + "schema-safe-report-demand-energy-aggregate", + new[] { leaf }); + } + + return null; + } + + private static NativeReportValueUpdate Synthesize( + string parentReference, + string value, + string projectionStatus, + IReadOnlyList leaves) + { + var latest = leaves + .OrderByDescending(leaf => leaf.UpdatedAt) + .First(); + var quality = SharedUseful(leaves.Select(leaf => leaf.Quality)); + var timestamp = SharedUseful(leaves.Select(leaf => leaf.Timestamp)); + var reasons = leaves + .Select(leaf => leaf.Reason) + .Where(IsUseful) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new NativeReportValueUpdate + { + Reference = parentReference, + FunctionalConstraint = latest.Source.FunctionalConstraint, + Value = value, + Quality = quality, + Timestamp = timestamp, + Reason = reasons.Length == 0 + ? "schema-safe static DataSet report aggregate" + : string.Join(",", reasons), + Source = "report", + ProjectionStatus = projectionStatus, + HasValue = true, + HasQuality = IsUsefulMetadata(quality), + HasTimestamp = IsUsefulMetadata(timestamp), + ReportControlReference = latest.Source.ReportControlReference, + ReportId = latest.Source.ReportId, + DataSetReference = latest.Source.DataSetReference, + SequenceNumber = latest.Source.SequenceNumber, + ConfRev = latest.Source.ConfRev, + ReportTimestamp = latest.Source.ReportTimestamp, + UpdatedAt = leaves.Max(leaf => leaf.UpdatedAt) + }; + } + + private static string SharedUseful(IEnumerable values) + { + var useful = values + .Where(IsUsefulMetadata) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return useful.Length == 1 ? useful[0] : string.Empty; + } + + private static bool IsAggregate(string? reference) + => SignalDefinition.IsThreePhaseMeasurementAggregate(reference) || + SignalDefinition.IsDemandEnergyAggregate(reference); + + private static bool IsDescendant(string child, string parent) + => !string.IsNullOrWhiteSpace(child) && + !string.IsNullOrWhiteSpace(parent) && + child.Length > parent.Length && + child.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase); + + private static bool ReferencesEqual(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".") + .ToLowerInvariant(); + + private static bool TryParseNumeric(string? value, out double numeric) + { + var token = (value ?? string.Empty) + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() ?? string.Empty; + return double.TryParse( + token, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out numeric); + } + + private static bool IsUseful(string? value) + => !string.IsNullOrWhiteSpace(value) && value != "-"; + + private static bool IsUsefulMetadata(string? value) + { + if (!IsUseful(value)) + return false; + + var normalized = value!.Trim(); + return !normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !normalized.StartsWith("Pending", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs new file mode 100644 index 000000000..2f499c89f --- /dev/null +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -0,0 +1,338 @@ +using System.Collections.Concurrent; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// UI-facing facade for the IEC 61850 runtime. +/// +/// Native/network calls must never execute their synchronous prefix on the WPF Dispatcher. +/// A Task-returning API is not sufficient protection because socket/vendor code may block +/// before the first asynchronous yield. +/// +/// Per IED, normal lifecycle/control operations remain serialized. Stop/Disconnect is a +/// deliberately pre-emptive lane: it cancels the active operation first and invokes the +/// runtime stop without waiting behind a hung Connect/Start gate. A cancelled/stale operation +/// is never allowed to report success to its caller afterwards. Different IEDs stay fully +/// independent. This facade changes lifecycle scheduling only; it does not add MMS polling, +/// dynamic DataSet writes, or any acquisition fallback. +/// +public sealed class Iec61850MonitorRuntime : IAsyncDisposable +{ + private static readonly TimeSpan DisposeBudget = TimeSpan.FromSeconds(3); + + private sealed class DeviceOperationSlot + { + public object SyncRoot { get; } = new(); + public SemaphoreSlim OperationGate { get; } = new(1, 1); + public SemaphoreSlim StopGate { get; } = new(1, 1); + public CancellationTokenSource? ActiveOperationCancellation { get; set; } + public long Generation { get; set; } + } + + private readonly Services.Iec61850MonitorRuntime _inner = new(); + private readonly ConcurrentDictionary _deviceSlots = + new(StringComparer.OrdinalIgnoreCase); + private int _disposeStarted; + + public Iec61850MonitorRuntime() + { + _inner.Diagnostic += entry => Diagnostic?.Invoke(entry); + _inner.PointUpdated += snapshot => PointUpdated?.Invoke(snapshot); + _inner.EventRaised += entry => EventRaised?.Invoke(entry); + } + + public event Action? Diagnostic; + public event Action? PointUpdated; + public event Action? EventRaised; + + public int ConnectedDeviceCount => _inner.ConnectedDeviceCount; + public int MonitoringDeviceCount => _inner.MonitoringDeviceCount; + + public Task> ConnectAndDiscoverAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + => RunDeviceOperationAsync( + device?.DeviceId, + cancellationToken, + token => _inner.ConnectAndDiscoverAsync(device, token, progress)); + + public Task ConnectUsingCachedModelAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + => RunDeviceOperationAsync( + device?.DeviceId, + cancellationToken, + token => _inner.ConnectUsingCachedModelAsync(device, token, progress)); + + public Task> StartMonitoringAsync( + Iec61850MonitorDevice device, + IEnumerable selectedSignals, + int pollingIntervalMs, + CancellationToken cancellationToken) + => RunDeviceOperationAsync( + device?.DeviceId, + cancellationToken, + token => _inner.StartMonitoringAsync(device, selectedSignals, pollingIntervalMs, token)); + + public Task InspectControlAsync( + string deviceId, + SignalDefinition signal, + CancellationToken cancellationToken) + => RunDeviceOperationAsync( + deviceId, + cancellationToken, + token => _inner.InspectControlAsync(deviceId, signal, token)); + + public Task ExecuteControlAsync( + string deviceId, + Iec61850ControlCommandRequest request, + CancellationToken cancellationToken) + => RunDeviceOperationAsync( + deviceId, + cancellationToken, + token => _inner.ExecuteControlAsync(deviceId, request, token)); + + public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) + => _inner.CaptureHybridReportPhysicalValidation(deviceId); + + public Task StopMonitoringAsync(string deviceId) + => RunPreemptiveStopAsync(deviceId, () => _inner.StopMonitoringAsync(deviceId)); + + public Task StopDeviceAsync(string deviceId) + => RunPreemptiveStopAsync(deviceId, () => _inner.StopDeviceAsync(deviceId)); + + private async Task RunDeviceOperationAsync( + string? deviceId, + CancellationToken cancellationToken, + Func operation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(operation); + + var slot = GetSlot(deviceId); + await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + CancellationTokenSource? linkedCancellation = null; + long generation = 0; + try + { + // A Stop that is already in progress owns StopGate. Waiting here prevents a new + // Connect/Start from racing the teardown. We hold StopGate only while publishing + // the new active-operation identity; never for the network operation itself. + await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (slot.SyncRoot) + { + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + } + finally + { + slot.StopGate.Release(); + } + + await Task.Run( + async () => await operation(linkedCancellation.Token).ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); + + // Stop/Disconnect can pre-empt this lane. Even if a native call ignored + // cancellation and eventually returned success, a pre-empted generation must + // not re-enter the UI as a successful stale Connect/Start operation. + linkedCancellation.Token.ThrowIfCancellationRequested(); + lock (slot.SyncRoot) + { + if (slot.Generation != generation) + throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded."); + } + } + finally + { + if (linkedCancellation != null) + { + lock (slot.SyncRoot) + { + if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + slot.ActiveOperationCancellation = null; + } + linkedCancellation.Dispose(); + } + slot.OperationGate.Release(); + } + } + + private async Task RunDeviceOperationAsync( + string? deviceId, + CancellationToken cancellationToken, + Func> operation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(operation); + + var slot = GetSlot(deviceId); + await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + CancellationTokenSource? linkedCancellation = null; + long generation = 0; + try + { + await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (slot.SyncRoot) + { + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + } + finally + { + slot.StopGate.Release(); + } + + var result = await Task.Run( + async () => await operation(linkedCancellation.Token).ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); + + linkedCancellation.Token.ThrowIfCancellationRequested(); + lock (slot.SyncRoot) + { + if (slot.Generation != generation) + throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded."); + } + return result; + } + finally + { + if (linkedCancellation != null) + { + lock (slot.SyncRoot) + { + if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + slot.ActiveOperationCancellation = null; + } + linkedCancellation.Dispose(); + } + slot.OperationGate.Release(); + } + } + + private async Task RunPreemptiveStopAsync(string? deviceId, Func stopOperation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(stopOperation); + + var slot = GetSlot(deviceId); + CancellationTokenSource? activeCancellation; + lock (slot.SyncRoot) + { + activeCancellation = slot.ActiveOperationCancellation; + ++slot.Generation; + } + + // Cancellation is intentionally issued before StopGate and, critically, without + // waiting for OperationGate. This is the escape path when Connect/Start is hung. + try + { + activeCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // The active operation completed between snapshot and cancellation. + } + + await slot.StopGate.WaitAsync().ConfigureAwait(false); + try + { + await Task.Run( + async () => await stopOperation().ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); + } + finally + { + slot.StopGate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + foreach (var slot in _deviceSlots.Values) + { + CancellationTokenSource? activeCancellation; + lock (slot.SyncRoot) + { + activeCancellation = slot.ActiveOperationCancellation; + ++slot.Generation; + } + try + { + activeCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // Benign completion race during process teardown. + } + } + + Task disposeTask; + try + { + disposeTask = Task.Run(async () => await _inner.DisposeAsync().ConfigureAwait(false)); + } + catch + { + return; + } + + var completed = await Task.WhenAny(disposeTask, Task.Delay(DisposeBudget)).ConfigureAwait(false); + if (completed == disposeTask) + { + try + { + await disposeTask.ConfigureAwait(false); + } + catch + { + // Shutdown is best-effort. Native teardown failure must not freeze WPF. + } + } + else + { + _ = disposeTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + // Do not Dispose the per-device semaphores here. An uncooperative native operation + // can still unwind after the bounded shutdown budget and its finally block must be + // able to Release() safely. They become process-lifetime garbage with this facade. + _deviceSlots.Clear(); + } + + private DeviceOperationSlot GetSlot(string? deviceId) + => _deviceSlots.GetOrAdd(NormalizeDeviceKey(deviceId), static _ => new DeviceOperationSlot()); + + private void ThrowIfDisposing() + { + if (Volatile.Read(ref _disposeStarted) != 0) + throw new ObjectDisposedException(nameof(Iec61850MonitorRuntime)); + } + + private static string NormalizeDeviceKey(string? deviceId) + => string.IsNullOrWhiteSpace(deviceId) ? "__unbound__" : deviceId.Trim(); +} diff --git a/docs/static-dataset-report-only-contract.md b/docs/static-dataset-report-only-contract.md new file mode 100644 index 000000000..b9df6ab8e --- /dev/null +++ b/docs/static-dataset-report-only-contract.md @@ -0,0 +1,47 @@ +# Static DataSet report-only contract + +This document is the acceptance boundary for ARSAS Static DataSet monitoring. + +## Acquisition authority + +- The imported/live ARIEC Static DataSet membership is the signal-selection source of truth. +- One user-visible runtime row is selected for each exact DataSet membership identity; browsed aliases do not become extra rows merely because they share a DataSetReference. +- Configured static BRCB/URCB plus General Interrogation are the process-value acquisition path. +- Cyclic MMS process polling is disabled in this mode. +- Dynamic DataSet writes are disabled in this mode. +- ARIEC planner fallback candidates are diagnostic evidence only and must never become silent MMS polling. + +Manual / Select Signals mode keeps the normal Hybrid acquisition behavior and is intentionally outside this contract. + +## Deterministic configured-RCB path + +Static DataSet mode does not pass configured SCL reporting through the adaptive Hybrid acquisition planner. The deterministic sequence is: + +1. Take the exact DataSet membership and RCB -> DataSet binding from the opened/live SCL model. +2. Verify that the exact configured RCB object exists on the active MMS association. +3. Read the exact live DataSet directory to obtain a non-empty ordered member list. This is report-index mapping evidence, not process-value polling. +4. Reject an explicit SCL/live `DatSet` mismatch; never guess another RCB or DataSet. +5. Install the InformationReport receiver before enabling reporting. +6. Write `RptEna=true`, then request `GI=true` through the normal one-shot MMS control plane. +7. Map report values by ordered DataSet member index and project them through the exact semantic report schema. + +Missing reservation metadata is not allowed to cancel an otherwise exact configured static RCB before an actual enable attempt. Conversely, a missing exact RCB, unreadable/empty DataSet directory, or explicit SCL/live binding mismatch fails closed. None of these conditions enables cyclic MMS process-value reads. + +## Structured report values + +ARIEC may decode one structured Static DataSet member into exact semantic scalar descendants. ARSAS may reconstruct the parent DataSet row only when the live/SCL schema proves every required descendant identity. This projection must fail closed: no positional phase guessing, prefix/fuzzy matching, sibling substitution, or MMS fallback is allowed. + +Examples covered by the schema-safe report projection are three-phase THD aggregates such as `ThdA` / `ThdPPV` and demand-energy aggregate values such as `DmdWhMV` when the authoritative model exposes the required scalar leaf. + +## Physical acceptance + +A Static DataSet physical test passes this contract only when: + +1. Live Signal Values contains the exact Static DataSet membership set rather than duplicate `cVal` / `instCVal` aliases. +2. The Acquisition column contains only configured static report acquisition (`StaticBrcb` / `StaticUrcb`) for resolved values; it contains no `MMS polling` rows. +3. General Interrogation can supply the initial report image for schema-safe reportable members. +4. A DataSet with no configured RCB remains explicitly unavailable instead of being sent through Hybrid planning or MMS polling. +5. Unsupported or ambiguous report projection remains explicitly Pending/unresolved instead of silently switching acquisition method. +6. Stop/start/reconnect and FAT attach/detach preserve the same report-only authority without freezing the WPF UI. + +This keeps the Static DataSet workflow deterministic and prevents a readability or feature change from regressing the established report-only protocol contract. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2e101141b..5d8014d0b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "b9ee5fc9650b72e69bc81287a1e1b047ad19b054", - "sourcePullRequest": 106, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam." + "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." } diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs new file mode 100644 index 000000000..af8ab324a --- /dev/null +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -0,0 +1,78 @@ +namespace ARSAS.Tests; + +public sealed class DeterministicStaticReportPathRegressionTests +{ + [Fact] + public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); + Assert.Contains("model.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: true", source, StringComparison.Ordinal); + Assert.Contains("deleteDynamicDataSetOnStop: false", source, StringComparison.Ordinal); + Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal); + Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal); + + Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal); + Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("ReadValueAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); + Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal); + Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); + Assert.Contains("directory.Members", source, StringComparison.Ordinal); + Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); + Assert.Contains("SCL binds", source, StringComparison.Ordinal); + Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); + } + + [Fact] + public void HybridEntryPoints_RouteStaticModeToDeterministicPath() + { + var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)", hybrid, StringComparison.Ordinal); + Assert.Contains("BuildStaticDataSetReportPlansAsync", hybrid, StringComparison.Ordinal); + Assert.Contains("_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)", hybrid, StringComparison.Ordinal); + Assert.Contains("StartStaticDataSetReportMonitorAsync", hybrid, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)", hybrid, StringComparison.Ordinal); + } + + [Fact] + public void ManualHybridPlanner_RemainsAvailableOutsideStaticRoute() + { + var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", hybrid, StringComparison.Ordinal); + Assert.Contains("AllowDynamicBrcb", hybrid, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", hybrid, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs new file mode 100644 index 000000000..eb7b3650e --- /dev/null +++ b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs @@ -0,0 +1,70 @@ +namespace ARSAS.Tests; + +public sealed class GlobalSntpLifecycleRegressionTests +{ + [Fact] + public void MainHeader_ExposesGlobalSntpToggleWithActualBoundServerIp() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSyncToggle.cs")); + + Assert.Contains("WorkflowNavShell.Parent is not Grid headerGrid", source, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpServerToggle\"", source, StringComparison.Ordinal); + Assert.Contains("Grid.SetColumn(toggle, 2)", source, StringComparison.Ordinal); + Assert.Contains("SNTP Server Active:", source, StringComparison.Ordinal); + Assert.Contains("snapshot.Binding?.LocalAddress", source, StringComparison.Ordinal); + Assert.Contains("Directed broadcast", source, StringComparison.Ordinal); + Assert.Contains("continues outside FAT", source, StringComparison.Ordinal); + } + + [Fact] + public void GlobalLifecycle_FollowsConnectedIedsAndCannotRestartAfterToggleOff() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSync.cs")); + + Assert.Contains("Devices.CollectionChanged += ClockSyncDevices_CollectionChanged", source, StringComparison.Ordinal); + Assert.Contains("device.PropertyChanged += ClockSyncDevice_PropertyChanged", source, StringComparison.Ordinal); + Assert.Contains("EnsureStartedAsync(iedAddress", source, StringComparison.Ordinal); + Assert.Contains("if (!_clockSyncEnabled || !device.IsConnected)", source, StringComparison.Ordinal); + Assert.Contains("Closed += ClockSyncMainWindow_Closed", source, StringComparison.Ordinal); + Assert.Contains("await _sntpClockService.DisposeAsync()", source, StringComparison.Ordinal); + } + + [Fact] + public void FatWorkspace_IsPassiveConsumerOfGlobalSntpState() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ClockSyncUx.cs")); + + Assert.Contains("Global SNTP is controlled from the ARSAS header", source, StringComparison.Ordinal); + Assert.Contains("continues running when the FAT window closes", source, StringComparison.Ordinal); + Assert.DoesNotContain("new CheckBox", source, StringComparison.Ordinal); + Assert.DoesNotContain("SetClockSyncEnabledAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("ClockSyncCheckBox_Changed", source, StringComparison.Ordinal); + } + + [Fact] + public void GlobalService_RemainsARealUdp123ServerAndMode5Broadcaster() + { + var source = File.ReadAllText(FindRepoFile("Services/SntpClockService.cs")); + + Assert.Contains("new IPEndPoint(binding.LocalAddress, 123)", source, StringComparison.Ordinal); + Assert.Contains("SntpPacket.BuildServerReply", source, StringComparison.Ordinal); + Assert.Contains("SntpPacket.BuildBroadcast", source, StringComparison.Ordinal); + Assert.Contains("Mode 5 broadcast", source, StringComparison.Ordinal); + Assert.Contains("SendBroadcastPacketAsync", source, StringComparison.Ordinal); + Assert.Contains("TimeSpan.FromSeconds(64)", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs index 6399a9938..929cfe1b4 100644 --- a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs @@ -5,14 +5,15 @@ namespace ARSAS.Tests; public sealed class IoFatP53PartialLiveRegressionTests { [Fact] - public void Preparation_AllowsUsablePartialSclLiveScopeWithoutMutatingOperatorSelection() + public void Preparation_AllowsUsablePartialSclBindingWithoutMutatingOperatorSelection() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("mayProceedWithPartialSclSelection", source, StringComparison.Ordinal); Assert.Contains("selection.Matches.Count > 0", source, StringComparison.Ordinal); Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority)", source, StringComparison.Ordinal); - Assert.Contains("if (liveCount == 0)", source, StringComparison.Ordinal); + Assert.Contains("if (boundCount == 0)", source, StringComparison.Ordinal); + Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.DoesNotContain("RemoveFromFat()", source, StringComparison.Ordinal); Assert.Contains("checkbox or FAT disposition is changed by the engine", source, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 047813963..74d798cdf 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -160,13 +160,16 @@ public void PreviewKeepsProgressOnIedCardOnly() } [Fact] - public void IoFatPreparation_UsesCachedReconnect_AndReturnsImmediatelyAfterMonitorStart() + public void IoFatPreparation_UsesCachedReconnect_AndAttachesToSharedMonitorWithoutRestart() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("Fast reconnect", source, StringComparison.Ordinal); Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal); - Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("Bind FAT rows to the already-owned per-IED monitor points", source, StringComparison.Ordinal); Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.Contains("IsReportSource", source, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs index 9048e1342..9f562587a 100644 --- a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs +++ b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs @@ -8,9 +8,10 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP var main = Read("MainWindow.IoTesting.cs"); var planner = Read("Services/Iec61850ReportPlanner.cs"); - // FAT may temporarily tighten MMS verification cadence, but that cadence must not - // classify fast status/protection points as polling-only. Report planning remains - // static first, then dynamic, with MMS polling reserved for the residual fallback. + // Legacy workbook-only FAT may temporarily tighten MMS verification cadence, but + // that cadence must not classify fast status/protection points as polling-only. + // Shared SCL workspaces are guarded separately: FAT consumes the Engineering + // acquisition session and never owns a second polling runtime. Assert.Contains("private const int IoFatPollingIntervalMs = 250;", main, StringComparison.Ordinal); Assert.Contains("_pollingIntervalBeforeIoFat", main, StringComparison.Ordinal); Assert.Contains("PollingIntervalMs = Math.Min(PollingIntervalMs, IoFatPollingIntervalMs);", main, StringComparison.Ordinal); @@ -22,13 +23,15 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP } [Fact] - public void FatConnect_ReadinessDoesNotBlockOnLegacyEightToEighteenSecondReportSettle() + public void SharedSclFat_ReadinessDoesNotRestartMonitorOrWaitForLegacyReportSettle() { var source = Read("MainWindow.IoTesting.AutoConnect.cs"); - Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); - Assert.Contains("fast MMS verification", source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromSeconds(10)", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs index 1ad83cc40..1e430db40 100644 --- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs +++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs @@ -162,15 +162,16 @@ public void IoTestingWindow_UsesSelectedIedContextAndWorkspacePreviewToggle() } [Fact] - public void IoFatAutomaticPreparation_UsesFastCommissioningAcquisitionWithoutProcessControls() + public void IoFatAutomaticPreparation_ReusesSharedSclAcquisitionWithoutProcessControls() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); - Assert.Contains("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); - Assert.Contains("ConnectAndConfigureDeviceAsync", source, StringComparison.Ordinal); - Assert.Contains("StartDeviceMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("deterministic fast MMS", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Return control to FAT immediately", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("sharedStaticDataSetAuthority", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs new file mode 100644 index 000000000..3594d1ace --- /dev/null +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -0,0 +1,77 @@ +namespace ARSAS.Tests; + +public sealed class P0LifecycleAndTotPfRegressionTests +{ + [Fact] + public void MainWindow_RuntimeFacade_OffloadsLifecycle_AndStopCanPreemptHungOperation() + { + var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); + var main = Read("MainWindow.xaml.cs"); + + Assert.Contains("namespace ArIED61850Tester;", facade, StringComparison.Ordinal); + Assert.Contains("public sealed class Iec61850MonitorRuntime", facade, StringComparison.Ordinal); + Assert.Contains("Services.Iec61850MonitorRuntime _inner", facade, StringComparison.Ordinal); + Assert.Contains("ConcurrentDictionary _deviceSlots", facade, StringComparison.Ordinal); + Assert.Contains("SemaphoreSlim OperationGate", facade, StringComparison.Ordinal); + Assert.Contains("SemaphoreSlim StopGate", facade, StringComparison.Ordinal); + Assert.Contains("RunDeviceOperationAsync", facade, StringComparison.Ordinal); + Assert.Contains("RunPreemptiveStopAsync", facade, StringComparison.Ordinal); + Assert.Contains("activeCancellation?.Cancel()", facade, StringComparison.Ordinal); + Assert.Contains("without\n // waiting for OperationGate", facade, StringComparison.Ordinal); + Assert.Contains("linkedCancellation.Token.ThrowIfCancellationRequested()", facade, StringComparison.Ordinal); + Assert.Contains("slot.Generation != generation", facade, StringComparison.Ordinal); + Assert.Contains("DisposeBudget = TimeSpan.FromSeconds(3)", facade, StringComparison.Ordinal); + Assert.Contains("Do not Dispose the per-device semaphores here", facade, StringComparison.Ordinal); + + // MainWindow intentionally uses the unqualified type from its own namespace, so the + // responsive facade is the UI boundary without altering protocol/runtime ownership. + Assert.Contains("private readonly Iec61850MonitorRuntime _runtime = new();", main, StringComparison.Ordinal); + } + + [Fact] + public void FatWindow_Close_KeepsUiBoundSessionMutationOnDispatcher_AndOffloadsPersistence() + { + var lifecycle = Read("IoListTestingWindow.P0Lifecycle.cs"); + + Assert.Contains("Closing -= Window_Closing", lifecycle, StringComparison.Ordinal); + Assert.Contains("Closing += P0Window_Closing", lifecycle, StringComparison.Ordinal); + Assert.Contains("var stopAll = Session.StopAll(", lifecycle, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Run(() =>\n Session.StopAll", lifecycle, StringComparison.Ordinal); + Assert.Contains("await Task.Run(Storage.SaveNow)", lifecycle, StringComparison.Ordinal); + Assert.Contains("e.Cancel = true", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallback() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + var semanticBridge = Read("Services/NativeIec61850Client.SemanticReporting.cs"); + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); + + Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); + Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal); + Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); + Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); + Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); + Assert.Contains(".mag.f", projection, StringComparison.OrdinalIgnoreCase); + Assert.Contains(".instmag.f", projection, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 7c01ed719..50a41a62b 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -18,7 +18,7 @@ public void StaticMode_DisablesDynamicWrites_AndManualModeRestoresPriorValue() } [Fact] - public void StaticSelection_AllowsOnlyRuntimeSignalsWithExplicitDataSetAuthority() + public void StaticSelectionPolicy_StillRejectsBrowsedAndControlSignals() { var dataSetLeaf = Signal("IEDLD/MMXU1.TotW.mag.f", "IEDLD/LLN0.Analog"); var browsedLeaf = Signal("IEDLD/MMXU1.Hz.mag.f", string.Empty); @@ -40,13 +40,105 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol } [Fact] - public void SharedSclStaticSelection_UsesDatasetAuthorityPolicy_NotSelectEverything() + public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias() { var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs")); + Assert.Contains("Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticDataSetAuthoritySelection.Build(device)", source, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected = authoritativeSignals.Contains(signal)", source, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", source, StringComparison.Ordinal); - Assert.Contains("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal); - Assert.DoesNotContain("signal.IsSelected = !string.IsNullOrWhiteSpace(signal.DataSetReference)", source, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal); + Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal); + Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); + + Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); + Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal); + } + + [Fact] + public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() + { + var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var registry = File.ReadAllText(FindRepoFile("Services/Iec61850MonitoringModeRegistry.cs")); + + Assert.Contains("UseStaticDataSetReportOnly(device)", shared, StringComparison.Ordinal); + Assert.Contains("Static DataSet report-only authority selected", shared, StringComparison.Ordinal); + Assert.Contains("cyclic MMS process polling and dynamic DataSet writes remain disabled", shared, StringComparison.Ordinal); + Assert.Contains("state.StaticDataSetReportOnly = true", registry, StringComparison.Ordinal); + Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", registry, StringComparison.Ordinal); + } + + [Fact] + public void FatMode_ReusesSharedStaticDataSetAuthority_InsteadOfDemotingToMms() + { + var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var fatMonitor = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.MultiIedMonitor.cs")); + + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds", shared, StringComparison.Ordinal); + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId)", shared, StringComparison.Ordinal); + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId)", shared, StringComparison.Ordinal); + Assert.Contains("IsSharedStaticDataSetAuthority(device)", fatMonitor, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", fatMonitor, StringComparison.Ordinal); + Assert.Contains("FAT reuses the shared Static DataSet report-only authority", fatMonitor, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitoringModeRegistry.UseHybrid(device)", fatMonitor, StringComparison.Ordinal); + } + + [Fact] + public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonitorOwner() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("var reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("one IED owns one acquisition session", source, StringComparison.Ordinal); + Assert.Contains("FAT is only a", source, StringComparison.Ordinal); + Assert.Contains("consumer", source, StringComparison.Ordinal); + Assert.Contains("if (reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); + + // The legacy helper may remain for workbook-only FAT, but the shared SCL branch + // must be structurally separate and must start through the normal Engineering owner. + var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal); + var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal); + Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart); + + var sharedBranch = source[sharedBranchStart..legacyBranchStart]; + Assert.DoesNotContain("StopDeviceMonitorAsync(device)", sharedBranch, StringComparison.Ordinal); + Assert.DoesNotContain("StartIoFatDeviceMonitorAsync(device", sharedBranch, StringComparison.Ordinal); + Assert.DoesNotContain("initial MMS", sharedBranch, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SharedStaticFat_DoesNotAddSupplementalSignalOrMisreportPendingAsReportBacked() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device)", source, StringComparison.Ordinal); + Assert.Contains("source.Contains(\"pending\"", source, StringComparison.Ordinal); + Assert.Contains("source.Contains(\"unavailable\"", source, StringComparison.Ordinal); + Assert.Contains("static report live", source, StringComparison.Ordinal); + Assert.Contains("pending/unavailable", source, StringComparison.Ordinal); + Assert.Contains("MMS polling", source, StringComparison.Ordinal); + } + + [Fact] + public void SharedStaticFat_EmitsCausalReportEvidenceWithoutMmsFallback() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("ObserveSharedStaticReportEvidenceAsync", source, StringComparison.Ordinal); + Assert.Contains("waiting for actual InformationReport traffic", source, StringComparison.Ordinal); + Assert.Contains("actual InformationReport traffic observed", source, StringComparison.Ordinal); + Assert.Contains("no InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("will NOT switch process values to cyclic MMS polling", source, StringComparison.Ordinal); } private static SignalDefinition Signal(string reference, string dataSetReference) diff --git a/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs new file mode 100644 index 000000000..474b5c545 --- /dev/null +++ b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs @@ -0,0 +1,303 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticDataSetReportProjectionAccumulatorTests +{ + [Fact] + public void ThdA_SemanticLeaves_ReconstructOneStaticParentValue() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"))); + var point = Point(parent); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsA.cVal.mag.f", "1.25"))); + Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsB.cVal.mag.f", "2.5"))); + + var projected = Assert.Single(accumulator.Project( + model, + new[] { point }, + Update(parent + ".phsC.cVal.mag.f", "3.75"))); + + Assert.Equal(parent, projected.Reference, ignoreCase: true); + Assert.Equal("A=1.25, B=2.5, C=3.75", projected.Value); + Assert.Equal("good", projected.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:00:00.000", projected.Timestamp); + Assert.Equal("schema-safe-report-three-phase-aggregate", projected.ProjectionStatus); + Assert.True(projected.HasValue); + Assert.True(projected.HasQuality); + Assert.True(projected.HasTimestamp); + } + + [Fact] + public void ReportCompanionQualityAndTimestamp_ArrivingBeforeSemanticValue_ArePreserved() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + const string phaseA = parent + ".phsA.cVal.mag.f"; + const string phaseB = parent + ".phsB.cVal.mag.f"; + const string phaseC = parent + ".phsC.cVal.mag.f"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(phaseA, "phsA.cVal.mag.f"), + Attribute(phaseB, "phsB.cVal.mag.f"), + Attribute(phaseC, "phsC.cVal.mag.f"))); + var parentPoint = Point(parent); + var phaseAPoint = Point(phaseA); + var monitored = new[] { parentPoint, phaseAPoint }; + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + // ARIEC may expose q/t on the phase structure before its semantic cVal.mag.f leaf. + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsA")); + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsB")); + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsC")); + + var phaseAUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseA, "1")); + var exactPhaseA = Assert.Single(phaseAUpdates, value => + value.Reference.Equals(phaseA, StringComparison.OrdinalIgnoreCase)); + Assert.Equal("questionable", exactPhaseA.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:20:51.480", exactPhaseA.Timestamp); + Assert.True(exactPhaseA.HasQuality); + Assert.True(exactPhaseA.HasTimestamp); + + Assert.Empty(accumulator.Project(model, monitored, ValueOnlyUpdate(phaseB, "2"))); + var finalUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseC, "3")); + var aggregate = Assert.Single(finalUpdates, value => + value.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase)); + Assert.Equal("A=1, B=2, C=3", aggregate.Value); + Assert.Equal("questionable", aggregate.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:20:51.480", aggregate.Timestamp); + Assert.True(aggregate.HasQuality); + Assert.True(aggregate.HasTimestamp); + } + + [Fact] + public void ThdA_InstantaneousSibling_DoesNotOverrideCanonicalCValPlan() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsA.instCVal.mag.f", "phsA.instCVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"))); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".phsA.instCVal.mag.f", "99"))); + } + + [Fact] + public void DmdWhMV_ExactMagnitude_ReconstructsParentWithoutMmsRead() + { + const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV"; + var model = Model(DataObject( + parent, + "DmdWhMV", + Attribute(parent + ".instMag.f", "instMag.f"), + Attribute(parent + ".mag.f", "mag.f"))); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".instMag.f", "9.9"))); + + var projected = Assert.Single(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".mag.f", "1.055"))); + + Assert.Equal(parent, projected.Reference, ignoreCase: true); + Assert.Equal("1.055", projected.Value); + Assert.Equal("schema-safe-report-demand-energy-aggregate", projected.ProjectionStatus); + } + + [Fact] + public void ExactSelectedChild_IsPreservedAlongsideCompletedParentAggregate() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + const string child = "IEDLD0/I_MHAI1.ThdA.phsC.cVal.mag.f"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(child, "phsC.cVal.mag.f"))); + var parentPoint = Point(parent); + var childPoint = Point(child); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsA.cVal.mag.f", "1")); + accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsB.cVal.mag.f", "2")); + var updates = accumulator.Project(model, new[] { parentPoint, childPoint }, Update(child, "3")); + + Assert.Equal(2, updates.Count); + Assert.Contains(updates, update => update.Reference.Equals(child, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(updates, update => update.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void RuntimeSource_StaticMode_UsesSchemaSafeReportAuthority_AndNoMmsFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + Assert.Contains("StaticDataSetReportProjectionAccumulator StaticReportProjection", source, StringComparison.Ordinal); + Assert.Contains("!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority", source, StringComparison.Ordinal); + Assert.Contains("HasSchemaProvenReportValueAuthority(point, update)", source, StringComparison.Ordinal); + Assert.Contains("Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling", source, StringComparison.Ordinal); + Assert.DoesNotContain("structured report projection is not yet schema-proven; unsafe MMS fallback is disabled", source, StringComparison.Ordinal); + } + + [Fact] + public void IpWizard_DoesNotRunEditableAddressBindingOnEveryKeystroke() + { + var xaml = File.ReadAllText(FindRepoFile("IpConnectWizardWindow.xaml")); + + Assert.Contains("IsTextSearchEnabled=\"False\"", xaml, StringComparison.Ordinal); + Assert.Contains("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus", xaml, StringComparison.Ordinal); + Assert.DoesNotContain("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged", xaml, StringComparison.Ordinal); + } + + private static NativeReportValueUpdate Update(string reference, string value) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = value, + Quality = "good", + Timestamp = "2026-09-04 16:00:00.000", + Reason = "integrity", + ProjectionStatus = "semantic-structured-leaf", + HasValue = true, + HasQuality = true, + HasTimestamp = true, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 0, 0, TimeSpan.FromHours(7)) + }; + + private static NativeReportValueUpdate CompanionUpdate(string reference) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = "-", + Quality = "questionable", + Timestamp = "2026-09-04 16:20:51.480", + Reason = "integrity", + ProjectionStatus = "measurement-companion", + HasValue = false, + HasQuality = true, + HasTimestamp = true, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 480, TimeSpan.FromHours(7)) + }; + + private static NativeReportValueUpdate ValueOnlyUpdate(string reference, string value) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = value, + Quality = string.Empty, + Timestamp = string.Empty, + Reason = "integrity", + ProjectionStatus = "semantic-structured-leaf", + HasValue = true, + HasQuality = false, + HasTimestamp = false, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 481, TimeSpan.FromHours(7)) + }; + + private static Iec61850MonitorPoint Point(string reference) + => new() + { + DeviceId = "IED", + DeviceName = "IED", + SignalName = reference.Split('.').Last(), + IecReference = reference, + FunctionalConstraint = "MX", + IecDataType = "FLOAT32", + Category = "DataSet", + DataSetReference = "IEDLD0/LLN0.Analog" + }; + + private static LiveIedModelDiscoveryDocument Model(params LiveIedDataObjectModel[] objects) + => new() + { + Source = "Static report regression", + IedName = "IED", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = objects + } + ] + } + ] + }; + + private static LiveIedDataObjectModel DataObject( + string reference, + string name, + params LiveIedDataAttributeModel[] attributes) + => new() + { + Reference = reference, + Name = name, + InferredCdc = "MV", + Attributes = attributes + }; + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + MmsReference = reference.Replace('.', '$'), + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +}