diff --git a/MainWindow.HybridAcquisitionDiagnostics.cs b/MainWindow.HybridAcquisitionDiagnostics.cs new file mode 100644 index 000000000..4136bfa49 --- /dev/null +++ b/MainWindow.HybridAcquisitionDiagnostics.cs @@ -0,0 +1,239 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + public ObservableCollection HybridAcquisitionTelemetry { get; } = new(); + + private DispatcherTimer? _hybridAcquisitionTelemetryTimer; + private TextBlock? _hybridAcquisitionSummaryText; + private bool _hybridAcquisitionDiagnosticsInstalled; + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + Loaded += MainWindow_HybridAcquisitionDiagnosticsLoaded; + Closed += MainWindow_HybridAcquisitionDiagnosticsClosed; + } + + private void MainWindow_HybridAcquisitionDiagnosticsLoaded(object sender, RoutedEventArgs e) + { + InstallHybridAcquisitionDiagnosticsPanel(); + MainTabs.SelectionChanged += MainTabs_HybridAcquisitionDiagnosticsSelectionChanged; + _hybridAcquisitionTelemetryTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromSeconds(1.5) + }; + _hybridAcquisitionTelemetryTimer.Tick += (_, _) => + { + if (MainTabs.SelectedIndex == 5) + RefreshHybridAcquisitionTelemetry(); + }; + _hybridAcquisitionTelemetryTimer.Start(); + } + + private void MainWindow_HybridAcquisitionDiagnosticsClosed(object? sender, EventArgs e) + { + if (_hybridAcquisitionTelemetryTimer is not null) + { + _hybridAcquisitionTelemetryTimer.Stop(); + _hybridAcquisitionTelemetryTimer = null; + } + } + + private void MainTabs_HybridAcquisitionDiagnosticsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (MainTabs.SelectedIndex == 5) + RefreshHybridAcquisitionTelemetry(); + } + + private void InstallHybridAcquisitionDiagnosticsPanel() + { + if (_hybridAcquisitionDiagnosticsInstalled) + return; + + var diagnosticsTab = MainTabs.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Header?.ToString(), "Diagnostics", StringComparison.Ordinal)); + if (diagnosticsTab?.Content is not Border workspace || workspace.Child is not DockPanel dock) + return; + + _hybridAcquisitionDiagnosticsInstalled = true; + + var shell = new Border + { + Margin = new Thickness(0, 0, 0, 10), + Padding = new Thickness(10, 9, 10, 8), + Background = new SolidColorBrush(Color.FromRgb(248, 251, 255)), + BorderBrush = new SolidColorBrush(Color.FromRgb(216, 228, 244)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12) + }; + DockPanel.SetDock(shell, Dock.Top); + + var layout = new Grid(); + layout.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(7) }); + layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(228) }); + + var header = new Grid(); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var heading = new StackPanel(); + heading.Children.Add(new TextBlock + { + Text = "IEC 61850 signal acquisition evidence", + FontSize = 12.8, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(31, 41, 55)) + }); + _hybridAcquisitionSummaryText = new TextBlock + { + Text = "Start monitoring to capture engine-authoritative per-signal acquisition evidence.", + Margin = new Thickness(0, 2, 0, 0), + FontSize = 10.3, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)) + }; + heading.Children.Add(_hybridAcquisitionSummaryText); + header.Children.Add(heading); + + var refresh = new Button + { + Content = "Refresh acquisition", + Padding = new Thickness(10, 5, 10, 5), + MinHeight = 30, + ToolTip = "Re-capture ARIEC planning, activation, dynamic-attempt, fallback, and rollback evidence" + }; + if (TryFindResource("SoftButton") is Style buttonStyle) + refresh.Style = buttonStyle; + refresh.Click += (_, _) => RefreshHybridAcquisitionTelemetry(); + Grid.SetColumn(refresh, 1); + header.Children.Add(refresh); + layout.Children.Add(header); + + var grid = BuildHybridAcquisitionTelemetryGrid(); + Grid.SetRow(grid, 2); + layout.Children.Add(grid); + shell.Child = layout; + + // Existing diagnostics header stays first and the communication journal remains + // the fill element. The evidence panel is deliberately inserted between them. + dock.Children.Insert(Math.Min(1, dock.Children.Count), shell); + RefreshHybridAcquisitionTelemetry(); + } + + private DataGrid BuildHybridAcquisitionTelemetryGrid() + { + var grid = new DataGrid + { + ItemsSource = HybridAcquisitionTelemetry, + AutoGenerateColumns = false, + IsReadOnly = true, + EnableRowVirtualization = true, + EnableColumnVirtualization = true, + HeadersVisibility = DataGridHeadersVisibility.Column, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + RowHeight = 34 + }; + if (TryFindResource("ModernDataGrid") is Style gridStyle) + grid.Style = gridStyle; + + var rowStyle = new Style(typeof(DataGridRow)); + rowStyle.Setters.Add(new Setter(FrameworkElement.ToolTipProperty, new Binding(nameof(HybridSignalAcquisitionTelemetry.Detail)))); + grid.RowStyle = rowStyle; + + grid.Columns.Add(TextColumn("IED", nameof(HybridSignalAcquisitionTelemetry.DeviceName), 105)); + grid.Columns.Add(TextColumn("Signal", nameof(HybridSignalAcquisitionTelemetry.SignalName), 155)); + grid.Columns.Add(TextColumn("IEC reference", nameof(HybridSignalAcquisitionTelemetry.IecReference), 280)); + grid.Columns.Add(TextColumn("Final state", nameof(HybridSignalAcquisitionTelemetry.StateLabel), 178)); + grid.Columns.Add(TextColumn("Acquisition", nameof(HybridSignalAcquisitionTelemetry.AcquisitionKind), 125)); + grid.Columns.Add(TextColumn("Dynamic attempt", nameof(HybridSignalAcquisitionTelemetry.DynamicAttemptLabel), 130)); + grid.Columns.Add(TextColumn("Exact reason", nameof(HybridSignalAcquisitionTelemetry.ExactReason), 240)); + grid.Columns.Add(TextColumn("Cleanup", nameof(HybridSignalAcquisitionTelemetry.CleanupLabel), 120)); + grid.Columns.Add(TextColumn("RCB", nameof(HybridSignalAcquisitionTelemetry.ReportControlReference), 230)); + grid.Columns.Add(TextColumn("DataSet", nameof(HybridSignalAcquisitionTelemetry.DataSetReference), 230)); + return grid; + } + + private static DataGridTextColumn TextColumn(string header, string property, double width) + => new() + { + Header = header, + Binding = new Binding(property) { Mode = BindingMode.OneWay }, + Width = width + }; + + private void RefreshHybridAcquisitionTelemetry() + { + if (!_hybridAcquisitionDiagnosticsInstalled || Dispatcher.HasShutdownStarted) + return; + + var rows = new List(); + var snapshotCount = 0; + var staticCount = 0; + var dynamicCount = 0; + var dynamicFailedCount = 0; + var pollingCount = 0; + var pendingCount = 0; + + foreach (var device in Devices.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase)) + { + try + { + var snapshot = _runtime.CaptureHybridReportPhysicalValidation(device.DeviceId); + snapshotCount++; + rows.AddRange(snapshot.SignalTelemetry); + staticCount += snapshot.StaticReportSignalCount; + dynamicCount += snapshot.DynamicReportSignalCount; + dynamicFailedCount += snapshot.DynamicFailedPollingSignalCount; + pollingCount += snapshot.FinalPollingSignalCount; + pendingCount += snapshot.PendingSignalCount; + } + catch (InvalidOperationException) + { + // Device has no active runtime session. This is normal for offline SCL + // workspaces and disconnected IEDs, so it is not a diagnostics warning. + } + } + + rows = rows + .OrderBy(item => TelemetryStateOrder(item.State)) + .ThenBy(item => item.DeviceName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.IecReference, StringComparer.OrdinalIgnoreCase) + .ToList(); + + HybridAcquisitionTelemetry.Clear(); + foreach (var row in rows) + HybridAcquisitionTelemetry.Add(row); + + if (_hybridAcquisitionSummaryText is null) + return; + + _hybridAcquisitionSummaryText.Text = rows.Count == 0 + ? snapshotCount == 0 + ? "No active IEC 61850 runtime session. Connect/start monitoring to capture acquisition evidence." + : "Hybrid planner has not produced per-signal evidence yet. Start or re-arm monitoring." + : $"Signals {rows.Count:N0} • Static {staticCount:N0} • Dynamic {dynamicCount:N0} • Dynamic failed→polling {dynamicFailedCount:N0} • Final polling {pollingCount:N0} • Pending {pendingCount:N0}"; + } + + private static int TelemetryStateOrder(HybridSignalAcquisitionState state) + => state switch + { + HybridSignalAcquisitionState.DynamicFailedPolling => 0, + HybridSignalAcquisitionState.PollingFallback => 1, + HybridSignalAcquisitionState.Uncovered => 2, + HybridSignalAcquisitionState.Pending => 3, + HybridSignalAcquisitionState.DynamicReport => 4, + HybridSignalAcquisitionState.StaticReport => 5, + _ => 6 + }; +} diff --git a/Models/NativeHybridReportAcquisitionModels.cs b/Models/NativeHybridReportAcquisitionModels.cs index e4cf2d095..729242a5a 100644 --- a/Models/NativeHybridReportAcquisitionModels.cs +++ b/Models/NativeHybridReportAcquisitionModels.cs @@ -49,6 +49,84 @@ public sealed class NativeHybridPointAttemptEvidence !PollingFallbackReason.Equals("None", StringComparison.OrdinalIgnoreCase); } +/// +/// Final application-visible steady-state acquisition classification for one selected +/// signal. This does not replace ARIEC planning evidence; it combines that evidence with +/// the actual activation result so an operator can see why the signal is report-backed or +/// why MMS polling became the final fallback. +/// +public enum HybridSignalAcquisitionState +{ + Pending, + StaticReport, + DynamicReport, + DynamicFailedPolling, + PollingFallback, + Uncovered +} + +public sealed class HybridSignalAcquisitionTelemetry +{ + public string DeviceId { get; init; } = string.Empty; + public string DeviceName { get; init; } = string.Empty; + public string PointKey { get; init; } = string.Empty; + public string SignalName { get; init; } = string.Empty; + public string IecReference { get; init; } = string.Empty; + public HybridSignalAcquisitionState State { get; init; } + public string AcquisitionKind { get; init; } = string.Empty; + public string ReportControlReference { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public bool DynamicAttempted { get; init; } + public string DynamicAttemptState { get; init; } = string.Empty; + public string FailureReason { get; init; } = string.Empty; + public string PollingFallbackReason { get; init; } = string.Empty; + public bool CleanupAttempted { get; init; } + public bool CleanupSucceeded { get; init; } = true; + public string PlanningDetail { get; init; } = string.Empty; + public string RuntimeDetail { get; init; } = string.Empty; + + public bool IsReportBacked => State is HybridSignalAcquisitionState.StaticReport or HybridSignalAcquisitionState.DynamicReport; + public bool IsFinalPollingFallback => State is HybridSignalAcquisitionState.DynamicFailedPolling or HybridSignalAcquisitionState.PollingFallback; + + public string StateLabel => State switch + { + HybridSignalAcquisitionState.StaticReport => "STATIC REPORT", + HybridSignalAcquisitionState.DynamicReport => "DYNAMIC REPORT", + HybridSignalAcquisitionState.DynamicFailedPolling => "DYNAMIC FAILED → POLLING", + HybridSignalAcquisitionState.PollingFallback => "POLLING", + HybridSignalAcquisitionState.Uncovered => "UNCOVERED", + _ => "PENDING" + }; + + public string DynamicAttemptLabel => DynamicAttempted + ? string.IsNullOrWhiteSpace(DynamicAttemptState) ? "Attempted" : DynamicAttemptState + : string.IsNullOrWhiteSpace(DynamicAttemptState) ? "Not required / skipped" : DynamicAttemptState; + + public string CleanupLabel => !CleanupAttempted + ? "-" + : CleanupSucceeded ? "Rollback OK" : "ROLLBACK FAILED"; + + public string ExactReason + { + get + { + var parts = new[] { FailureReason, PollingFallbackReason } + .Where(value => !string.IsNullOrWhiteSpace(value) && !value.Equals("None", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (parts.Length > 0) + return string.Join(" • ", parts); + if (IsReportBacked) + return "Report active"; + return State == HybridSignalAcquisitionState.Pending ? "Activation pending" : "-"; + } + } + + public string Detail => string.Join(" | ", new[] { PlanningDetail, RuntimeDetail } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase)); +} + /// /// Physical-validation evidence collected by ARSAS after executing an engine-authoritative /// report plan against a real IED. Silence is never interpreted as signal absence. @@ -68,6 +146,11 @@ public sealed class HybridReportPhysicalValidationSnapshot public int DynamicAttemptedCount { get; init; } public int DynamicAttemptFailedCount { get; init; } public int DynamicSkippedPointCount { get; init; } + public int StaticReportSignalCount { get; init; } + public int DynamicReportSignalCount { get; init; } + public int DynamicFailedPollingSignalCount { get; init; } + public int FinalPollingSignalCount { get; init; } + public int PendingSignalCount { get; init; } public int ReportFrameCount { get; init; } public int ReportUpdateCount { get; init; } public int ChangeVerifiedPointCount { get; init; } @@ -75,6 +158,7 @@ public sealed class HybridReportPhysicalValidationSnapshot public int UncoveredPointCount { get; init; } public IReadOnlyList Plans { get; init; } = Array.Empty(); public IReadOnlyList PointAttemptEvidence { get; init; } = Array.Empty(); + public IReadOnlyList SignalTelemetry { get; init; } = Array.Empty(); public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool HasPhysicalReportEvidence => ReportFrameCount > 0 || ReportUpdateCount > 0; diff --git a/Services/HybridReportPhysicalValidationTracker.cs b/Services/HybridReportPhysicalValidationTracker.cs index e9e63db65..2efa5b758 100644 --- a/Services/HybridReportPhysicalValidationTracker.cs +++ b/Services/HybridReportPhysicalValidationTracker.cs @@ -165,6 +165,8 @@ public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice devi .ToArray(); var attemptEvidence = planning?.PointAttemptEvidence ?? Array.Empty(); + var signalTelemetry = BuildSignalTelemetry(device, planning, attemptEvidence); + return new HybridReportPhysicalValidationSnapshot { CapturedAtUtc = DateTimeOffset.UtcNow, @@ -180,6 +182,11 @@ public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice devi DynamicAttemptedCount = _plans.Values.Count(state => state.DynamicAttempted), DynamicAttemptFailedCount = _plans.Values.Count(state => state.DynamicAttempted && !state.ActivationSucceeded), DynamicSkippedPointCount = attemptEvidence.Count(item => item.IsExplicitDynamicSkip), + StaticReportSignalCount = signalTelemetry.Count(item => item.State == HybridSignalAcquisitionState.StaticReport), + DynamicReportSignalCount = signalTelemetry.Count(item => item.State == HybridSignalAcquisitionState.DynamicReport), + DynamicFailedPollingSignalCount = signalTelemetry.Count(item => item.State == HybridSignalAcquisitionState.DynamicFailedPolling), + FinalPollingSignalCount = signalTelemetry.Count(item => item.IsFinalPollingFallback), + PendingSignalCount = signalTelemetry.Count(item => item.State == HybridSignalAcquisitionState.Pending), ReportFrameCount = plans.Sum(plan => plan.ReportFrameCount), ReportUpdateCount = plans.Sum(plan => plan.ReportUpdateCount), ChangeVerifiedPointCount = plans.Sum(plan => plan.ChangeVerifiedPointCount), @@ -187,11 +194,134 @@ public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice devi UncoveredPointCount = planning?.UncoveredSignalCount ?? 0, Plans = plans, PointAttemptEvidence = attemptEvidence, + SignalTelemetry = signalTelemetry, Warnings = _warnings.ToArray() }; } } + private HybridSignalAcquisitionTelemetry[] BuildSignalTelemetry( + Iec61850MonitorDevice device, + NativeHybridReportPlanningResult? planning, + IReadOnlyList attemptEvidence) + { + if (planning is null) + return Array.Empty(); + + var evidenceByPoint = attemptEvidence + .Where(item => !string.IsNullOrWhiteSpace(item.PointKey)) + .GroupBy(item => item.PointKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var planByPoint = _plans.Values + .SelectMany(state => state.Plan.Bindings + .Where(point => !string.IsNullOrWhiteSpace(point.PointKey)) + .Select(point => new { point.PointKey, Point = point, State = state })) + .GroupBy(item => item.PointKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + // Polling-only signals have no report-plan binding. Preserve their complete runtime + // identity from the device point collection so Diagnostics never degrades into an + // anonymous PointKey/fallback-reason row. + var devicePointsByKey = device.Points + .Where(point => !string.IsNullOrWhiteSpace(point.PointKey)) + .GroupBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var pollingKeys = planning.PollingPointKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var uncoveredKeys = planning.UncoveredPointKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var allKeys = evidenceByPoint.Keys + .Concat(planByPoint.Keys) + .Concat(devicePointsByKey.Keys) + .Concat(pollingKeys) + .Concat(uncoveredKeys) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return allKeys.Select(key => + { + evidenceByPoint.TryGetValue(key, out var evidence); + planByPoint.TryGetValue(key, out var planBinding); + devicePointsByKey.TryGetValue(key, out var devicePoint); + var planState = planBinding?.State; + var point = planBinding?.Point ?? devicePoint; + var state = ClassifySignalState( + planState, + evidence, + pollingKeys.Contains(key), + uncoveredKeys.Contains(key)); + + var fallbackReason = FirstNonEmpty( + planState?.PollingFallbackReason, + evidence?.PollingFallbackReason); + var dynamicAttemptState = FirstNonEmpty( + planState?.DynamicAttemptState, + evidence?.DynamicAttemptDisposition); + + return new HybridSignalAcquisitionTelemetry + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + PointKey = key, + SignalName = point?.SignalName ?? string.Empty, + IecReference = FirstNonEmpty(point?.IecReference, evidence?.IecReference), + State = state, + AcquisitionKind = FirstNonEmpty(planState?.Plan.EngineAcquisitionKind, evidence?.PlannedAcquisitionKind), + ReportControlReference = planState?.Plan.ReportControlReference ?? string.Empty, + DataSetReference = planState?.Plan.DataSetReference ?? string.Empty, + DynamicAttempted = planState?.DynamicAttempted == true, + DynamicAttemptState = dynamicAttemptState, + FailureReason = planState?.FailureReason ?? string.Empty, + PollingFallbackReason = fallbackReason, + CleanupAttempted = planState?.CleanupAttempted == true, + CleanupSucceeded = planState?.CleanupSucceeded ?? true, + PlanningDetail = evidence?.Detail ?? string.Empty, + RuntimeDetail = planState?.ActivationMessage ?? string.Empty + }; + }).ToArray(); + } + + private static HybridSignalAcquisitionState ClassifySignalState( + PlanState? planState, + NativeHybridPointAttemptEvidence? evidence, + bool isPolling, + bool isUncovered) + { + if (planState is not null) + { + var kind = planState.Plan.EngineAcquisitionKind ?? string.Empty; + if (planState.ActivationSucceeded) + { + if (kind.StartsWith("Static", StringComparison.OrdinalIgnoreCase)) + return HybridSignalAcquisitionState.StaticReport; + if (kind.StartsWith("Dynamic", StringComparison.OrdinalIgnoreCase)) + return HybridSignalAcquisitionState.DynamicReport; + } + + if (planState.ActivationAttempted) + return planState.DynamicAttempted + ? HybridSignalAcquisitionState.DynamicFailedPolling + : HybridSignalAcquisitionState.PollingFallback; + + return HybridSignalAcquisitionState.Pending; + } + + if (isUncovered || evidence?.PlannedAcquisitionKind.Equals("Uncovered", StringComparison.OrdinalIgnoreCase) == true) + return HybridSignalAcquisitionState.Uncovered; + if (isPolling || evidence?.IsExplicitDynamicSkip == true || + evidence?.PlannedAcquisitionKind.Equals("MmsPollingFallback", StringComparison.OrdinalIgnoreCase) == true) + { + return HybridSignalAcquisitionState.PollingFallback; + } + + return HybridSignalAcquisitionState.Pending; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + private void AddWarning(string? warning) { var text = (warning ?? string.Empty).Trim(); diff --git a/tests/ARSAS.Tests/HybridSignalAcquisitionTelemetryP5Tests.cs b/tests/ARSAS.Tests/HybridSignalAcquisitionTelemetryP5Tests.cs new file mode 100644 index 000000000..c3ce989f6 --- /dev/null +++ b/tests/ARSAS.Tests/HybridSignalAcquisitionTelemetryP5Tests.cs @@ -0,0 +1,204 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class HybridSignalAcquisitionTelemetryP5Tests +{ + [Fact] + public void StaticActivation_IsVisibleAsFinalStaticReportPerSignal() + { + var point = Point("static", "IEDLD0/XCBR1.Pos.stVal"); + var plan = Plan("p-static", "StaticBrcb", point); + var tracker = Tracker(plan, Evidence(point, "StaticBrcb", "NotRequired", "None")); + + tracker.RecordActivation(plan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = "Static BRCB active" + }); + + var telemetry = Assert.Single(tracker.Capture(Device()).SignalTelemetry); + Assert.Equal(HybridSignalAcquisitionState.StaticReport, telemetry.State); + Assert.Equal("STATIC REPORT", telemetry.StateLabel); + Assert.True(telemetry.IsReportBacked); + Assert.False(telemetry.IsFinalPollingFallback); + Assert.Equal("Report active", telemetry.ExactReason); + } + + [Fact] + public void DynamicActivation_IsVisibleAsFinalDynamicReportPerSignal() + { + var point = Point("dynamic", "IEDLD0/PTOC1.Op.general"); + var plan = Plan("p-dynamic", "DynamicUrcb", point); + var tracker = Tracker(plan, Evidence(point, "DynamicUrcb", "Planned", "None")); + + tracker.RecordActivation(plan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = "Dynamic URCB active", + UsedDynamicDataSet = true, + DynamicAttempted = true, + DynamicAttemptState = "AttemptedSucceeded" + }); + + var snapshot = tracker.Capture(Device()); + var telemetry = Assert.Single(snapshot.SignalTelemetry); + Assert.Equal(HybridSignalAcquisitionState.DynamicReport, telemetry.State); + Assert.Equal("DYNAMIC REPORT", telemetry.StateLabel); + Assert.True(telemetry.DynamicAttempted); + Assert.Equal(1, snapshot.DynamicReportSignalCount); + Assert.Equal(0, snapshot.FinalPollingSignalCount); + } + + [Fact] + public void FailedDynamicAttempt_BecomesExplicitDynamicFailedPollingWithCleanupEvidence() + { + var point = Point("dynamic-failed", "IEDLD0/GGIO1.Ind1.stVal"); + var plan = Plan("p-dynamic-failed", "DynamicBrcb", point); + var tracker = Tracker(plan, Evidence(point, "DynamicBrcb", "Planned", "None")); + + tracker.RecordActivation(plan, new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = "RptEna write failed after temporary DataSet creation", + UsedDynamicDataSet = true, + DynamicAttempted = true, + DynamicAttemptState = "AttemptedFailed", + FailureReason = "ReportEnableFailed", + PollingFallbackReason = "DynamicActivationFailed", + CleanupAttempted = true, + CleanupSucceeded = true + }); + + var snapshot = tracker.Capture(Device()); + var telemetry = Assert.Single(snapshot.SignalTelemetry); + Assert.Equal(HybridSignalAcquisitionState.DynamicFailedPolling, telemetry.State); + Assert.Equal("DYNAMIC FAILED → POLLING", telemetry.StateLabel); + Assert.True(telemetry.IsFinalPollingFallback); + Assert.Equal("ReportEnableFailed • DynamicActivationFailed", telemetry.ExactReason); + Assert.Equal("Rollback OK", telemetry.CleanupLabel); + Assert.Equal(1, snapshot.DynamicFailedPollingSignalCount); + Assert.Equal(1, snapshot.FinalPollingSignalCount); + } + + [Fact] + public void ExplicitEngineSkip_IsVisibleAsFinalPollingWithExactReasonAndSignalIdentity() + { + var point = Point("polling", "IEDLD0/MMXU1.A.phsA.cVal.mag.f"); + var evidence = Evidence( + point, + "MmsPollingFallback", + "Skipped", + "DefineNamedVariableListUnsupported"); + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + PollingPointKeys = [point.PointKey], + PollingFallbackSignalCount = 1, + PointAttemptEvidence = [evidence] + }); + var device = Device(); + device.Points.Add(point); + + var snapshot = tracker.Capture(device); + var telemetry = Assert.Single(snapshot.SignalTelemetry); + Assert.Equal(HybridSignalAcquisitionState.PollingFallback, telemetry.State); + Assert.Equal("POLLING", telemetry.StateLabel); + Assert.False(telemetry.DynamicAttempted); + Assert.Equal("DefineNamedVariableListUnsupported", telemetry.ExactReason); + Assert.Equal("polling", telemetry.SignalName); + Assert.Equal(point.IecReference, telemetry.IecReference); + Assert.Equal(1, snapshot.FinalPollingSignalCount); + } + + [Fact] + public void DiagnosticsPanel_ShowsFinalStateExactReasonAttemptAndCleanupPerSignal() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.HybridAcquisitionDiagnostics.cs")); + + Assert.Contains("IEC 61850 signal acquisition evidence", source, StringComparison.Ordinal); + Assert.Contains("CaptureHybridReportPhysicalValidation", source, StringComparison.Ordinal); + Assert.Contains("HybridAcquisitionTelemetry", source, StringComparison.Ordinal); + Assert.Contains("Final state", source, StringComparison.Ordinal); + Assert.Contains("Dynamic attempt", source, StringComparison.Ordinal); + Assert.Contains("Exact reason", source, StringComparison.Ordinal); + Assert.Contains("Cleanup", source, StringComparison.Ordinal); + Assert.Contains("Dynamic failed→polling", source, StringComparison.Ordinal); + } + + private static HybridReportPhysicalValidationTracker Tracker( + ReportControlPlan plan, + NativeHybridPointAttemptEvidence evidence) + { + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + ReportPlans = [plan], + PointAttemptEvidence = [evidence] + }); + return tracker; + } + + private static NativeHybridPointAttemptEvidence Evidence( + Iec61850MonitorPoint point, + string kind, + string disposition, + string fallbackReason) + => new() + { + PointKey = point.PointKey, + IecReference = point.IecReference, + PlannedAcquisitionKind = kind, + DynamicAttemptDisposition = disposition, + PollingFallbackReason = fallbackReason, + Detail = $"P5 fixture: {kind}/{disposition}/{fallbackReason}" + }; + + private static ReportControlPlan Plan(string id, string kind, Iec61850MonitorPoint point) + => new() + { + PlanId = id, + IsEngineAuthoritative = true, + EngineAcquisitionKind = kind, + ReportControlReference = $"IEDLD0/LLN0.{(kind.Contains("Brcb", StringComparison.OrdinalIgnoreCase) ? "BR" : "RP")}.{id}", + DataSetReference = $"IEDLD0/LLN0.{id}-dataset", + Bindings = [point] + }; + + private static Iec61850MonitorPoint Point(string name, string reference) + => new() + { + DeviceId = "ied-1", + DeviceName = "IED-1", + SignalName = name, + IecReference = reference + }; + + private static Iec61850MonitorDevice Device() + => new() + { + DeviceId = "ied-1", + Name = "IED-1", + IpAddress = "192.0.2.10", + Port = 102 + }; + + 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); + } +}