diff --git a/IoListTestingWindow.FatV2Ux.cs b/IoListTestingWindow.FatV2Ux.cs index dfb4c4339..eec1fef5b 100644 --- a/IoListTestingWindow.FatV2Ux.cs +++ b/IoListTestingWindow.FatV2Ux.cs @@ -13,12 +13,12 @@ public partial class IoListTestingWindow private DataGrid? _fatSignalsGrid; private Button? _removedSignalsButton; private ICollectionView? _fatSignalsView; + private IoTestPointPlan? _fatContextPoint; private bool _fatV2UxInstalled; private readonly BooleanToVisibilityConverter _fatBooleanVisibility = new(); private readonly IoFatSelectedIedPlanEditabilityConverter _fatPlanEditabilityConverter = new(); + private readonly IoFatEffectiveValuePresentationConverter _fatEffectiveValueConverter = new(); - // P0.3/P2: plan ownership is per IED. Connection preparation and evidence sessions - // lock only the owning IED; sibling IEDs remain independently editable. public bool SelectedCanEditPlan => SelectedIed is not null && CanEditIedPlan(SelectedIed); private bool CanEditIedPlan(IoTestIedPlan ied) @@ -40,6 +40,10 @@ private void InstallFatV2WorkspaceUx() _fatSignalsGrid = FindVisualDescendant(this); if (_fatSignalsGrid != null) { + // P3 desktop selection contract: Ctrl+Click for disjoint rows, Shift+Click + // for ranges, and full-row context actions. + _fatSignalsGrid.SelectionMode = DataGridSelectionMode.Extended; + _fatSignalsGrid.SelectionUnit = DataGridSelectionUnit.FullRow; ConfigureFatV2Columns(_fatSignalsGrid); _fatSignalsGrid.PreviewMouseRightButtonDown += FatSignalsGrid_PreviewMouseRightButtonDown; _fatSignalsGrid.ContextMenu = BuildFatContextMenu(); @@ -55,8 +59,6 @@ private void InstallFatV2WorkspaceUx() private void RefreshFatV2WorkspaceUx(bool refreshRows = false) { - // Selected/session/preparation notifications all converge here through the selected - // IED context refresh. Re-evaluate the per-IED edit contract without touching rows. Raise(nameof(SelectedCanEditPlan)); Raise(nameof(CanRestoreAnyRemovedSignal)); @@ -66,9 +68,6 @@ private void RefreshFatV2WorkspaceUx(bool refreshRows = false) if (view != null && !ReferenceEquals(view, _fatSignalsView)) { _fatSignalsView = view; - // FAT projects the shared Engineering workspace selection. TEST is a FAT-only - // evidence-scope toggle and must never hide a shared signal. Remove/Restore is - // a second, orthogonal FAT-only disposition overlay. view.Filter = item => item is IoTestPointPlan point && point.IsIncludedInFat && point.WorkspaceSelected; @@ -103,9 +102,6 @@ private void ConfigureFatV2Columns(DataGrid grid) UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }); - // Bind directly to selected-IED/session/preparation sources so the active IED locks - // synchronously when Session.Start or IsPreparing changes. This avoids a dispatcher - // timing window where a stale global CanEditPlan value could accept one more click. var editability = new MultiBinding { Converter = _fatPlanEditabilityConverter }; editability.Bindings.Add(new Binding("DataContext.SelectedIed") { @@ -211,21 +207,15 @@ private DataGridTemplateColumn CreateValueColumn(FatValueSlot slot) panel.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 1, 0, 1)); var value = new FrameworkElementFactory(typeof(TextBlock)); - value.SetBinding(TextBlock.TextProperty, new Binding(slot == FatValueSlot.Value1 - ? nameof(IoTestPointPlan.Value1Text) - : nameof(IoTestPointPlan.Value2Text))); + value.SetBinding(TextBlock.TextProperty, EffectiveValueBinding(slot, "value")); value.SetValue(TextBlock.FontWeightProperty, FontWeights.SemiBold); value.SetValue(TextBlock.FontSizeProperty, 11.4); value.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis); - value.SetBinding(FrameworkElement.ToolTipProperty, new Binding(slot == FatValueSlot.Value1 - ? nameof(IoTestPointPlan.Value1EvidenceToolTip) - : nameof(IoTestPointPlan.Value2EvidenceToolTip))); + value.SetBinding(FrameworkElement.ToolTipProperty, EffectiveValueBinding(slot, "tooltip")); panel.AppendChild(value); var timestamp = new FrameworkElementFactory(typeof(TextBlock)); - timestamp.SetBinding(TextBlock.TextProperty, new Binding(slot == FatValueSlot.Value1 - ? nameof(IoTestPointPlan.Value1RelayTimestampText) - : nameof(IoTestPointPlan.Value2RelayTimestampText))); + timestamp.SetBinding(TextBlock.TextProperty, EffectiveValueBinding(slot, "timestamp")); timestamp.SetValue(TextBlock.FontSizeProperty, 8.8); timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); @@ -255,6 +245,19 @@ private DataGridTemplateColumn CreateValueColumn(FatValueSlot slot) }; } + private MultiBinding EffectiveValueBinding(FatValueSlot slot, string format) + { + var binding = new MultiBinding + { + Converter = _fatEffectiveValueConverter, + ConverterParameter = format + }; + binding.Bindings.Add(new Binding(slot == FatValueSlot.Value1 ? "Runtime.Value1Evidence" : "Runtime.Value2Evidence")); + binding.Bindings.Add(new Binding(slot == FatValueSlot.Value1 ? "Runtime.OnEvidence" : "Runtime.OffEvidence")); + binding.Bindings.Add(new Binding(nameof(IoTestPointPlan.CaptureMode))); + return binding; + } + private void CaptureValue1_Click(object sender, RoutedEventArgs e) => CaptureOperatorValue(sender, FatValueSlot.Value1); @@ -277,27 +280,82 @@ private void CaptureOperatorValue(object sender, FatValueSlot slot) private ContextMenu BuildFatContextMenu() { var menu = new ContextMenu(); + var recapture = new MenuItem { Header = "Recapture" }; + var value1 = new MenuItem { Header = "Value 1" }; + value1.Click += (_, _) => RecaptureSelected(FatValueSlot.Value1); + var value2 = new MenuItem { Header = "Value 2" }; + value2.Click += (_, _) => RecaptureSelected(FatValueSlot.Value2); + var both = new MenuItem { Header = "Value 1 & Value 2 (re-arm)" }; + both.Click += (_, _) => BeginPairRecaptureSelected(); + recapture.Items.Add(value1); + recapture.Items.Add(value2); + recapture.Items.Add(new Separator()); + recapture.Items.Add(both); + menu.Items.Add(recapture); + menu.Items.Add(new Separator()); + var remove = new MenuItem { Header = "Remove from FAT" }; remove.Click += RemoveSelectedFromFat_Click; menu.Items.Add(remove); return menu; } + private IReadOnlyList SelectedFatPoints() + { + if (_fatSignalsGrid == null) + return Array.Empty(); + return _fatSignalsGrid.SelectedItems + .OfType() + .Distinct() + .ToArray(); + } + + private void RecaptureSelected(FatValueSlot slot) + { + var selected = SelectedFatPoints(); + var result = Session.RecaptureBatch(selected, slot); + ShowActionResult(result, "Bulk recapture failed"); + PreparationStatusText = result.Message; + if (!result.Succeeded) + return; + Storage?.ScheduleSave(); + RaiseSelectedIedContextProperties(); + RefreshFatV2WorkspaceUx(); + } + + private void BeginPairRecaptureSelected() + { + var selected = SelectedFatPoints(); + var result = Session.BeginRecapturePair(selected); + ShowActionResult(result, "Value 1 & Value 2 re-arm failed"); + PreparationStatusText = result.Message; + RaiseSelectedIedContextProperties(); + } + private void FatSignalsGrid_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { if (_fatSignalsGrid == null) return; var row = FindVisualAncestor(e.OriginalSource as DependencyObject); - if (row?.Item is IoTestPointPlan point) + if (row?.Item is not IoTestPointPlan point) + return; + + _fatContextPoint = point; + // Desktop convention: right-clicking inside the existing selection preserves the + // whole Ctrl/Shift selection. Right-clicking an unselected row makes only that row + // the context target. + if (!row.IsSelected) { - _fatSignalsGrid.SelectedItem = point; + _fatSignalsGrid.SelectedItems.Clear(); row.IsSelected = true; + _fatSignalsGrid.SelectedItem = point; } } private void RemoveSelectedFromFat_Click(object sender, RoutedEventArgs e) { - if (_fatSignalsGrid?.SelectedItem is not IoTestPointPlan point) + var point = _fatContextPoint ?? _fatSignalsGrid?.SelectedItem as IoTestPointPlan; + if (point == null) return; if (!CanEditPointPlan(point)) { @@ -413,6 +471,42 @@ private static IEnumerable FindVisualDescendants(DependencyObject root) wh } } +public sealed class IoFatEffectiveValuePresentationConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var generic = values.Length > 0 ? values[0] as FatValueEvidence : null; + var transition = values.Length > 1 ? values[1] as IoTestTransitionEvidence : null; + var captureMode = values.Length > 2 && values[2] is FatCaptureMode mode ? mode : FatCaptureMode.OperatorSnapshot; + var format = parameter?.ToString() ?? "value"; + + if (generic != null) + { + return format switch + { + "timestamp" => Iec61850TimestampPresentation.FormatMilliseconds(generic.IedTimestamp, "yyyy-MM-dd HH:mm:ss.fff"), + "tooltip" => $"{generic.Slot}: {generic.RawValue}\nCapture: {generic.CaptureKind}\nQuality: {generic.Quality}\nSource: {generic.AcquisitionSource}\nARSAS: {generic.CapturedAt:O}", + _ => generic.RawValue + }; + } + + if (captureMode == FatCaptureMode.AutomaticTransition && transition != null) + { + return format switch + { + "timestamp" => Iec61850TimestampPresentation.FormatMilliseconds(transition.IedTimestamp, "yyyy-MM-dd HH:mm:ss.fff"), + "tooltip" => $"Automatic transition: {transition.RawValue}\n{transition.Verdict}: {transition.VerdictReason}\nQuality: {transition.Quality}\nSource: {transition.AcquisitionSource}", + _ => transition.RawValue + }; + } + + return format == "tooltip" ? "Value has not been captured." : "—"; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); +} + public sealed class IoFatSelectedIedPlanEditabilityConverter : IMultiValueConverter { public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) diff --git a/Models/IoTesting/FatVerificationModels.cs b/Models/IoTesting/FatVerificationModels.cs index e4587e290..52a61c0af 100644 --- a/Models/IoTesting/FatVerificationModels.cs +++ b/Models/IoTesting/FatVerificationModels.cs @@ -34,7 +34,9 @@ public enum FatValueSlot public enum FatEvidenceCaptureKind { AutomaticTransition, - OperatorSnapshot + AutomaticStableSnapshot, + OperatorSnapshot, + OperatorRecapture } public sealed record FatValueEvidence( diff --git a/Services/IoTesting/FatOperatorSnapshotCaptureService.cs b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs index a340e79da..d33b3c729 100644 --- a/Services/IoTesting/FatOperatorSnapshotCaptureService.cs +++ b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs @@ -3,9 +3,9 @@ namespace ArIED61850Tester.Services.IoTesting; /// -/// Captures the value currently read from the IED when the FAT operator explicitly -/// confirms Value 1 or Value 2. No injected/reference value or tolerance exists here; -/// this first public milestone proves trustworthy reading capture only. +/// Creates immutable FAT Value 1 / Value 2 evidence from a trustworthy live observation. +/// Pointer promotion is intentionally owned by the session controller so journal writes +/// happen first and batch recapture can remain transactional. /// public static class FatOperatorSnapshotCaptureService { @@ -29,19 +29,25 @@ public static FatValueEvidence Capture( $"FAT signal '{signal.SignalName}' uses {signal.CaptureMode} capture and cannot be manually snapshotted."); } - var evidence = CreateEvidence(slot, observation); + var evidence = CreateEvidence(slot, observation, FatEvidenceCaptureKind.OperatorSnapshot); signal.SetCurrentEvidence(evidence); return evidence; } + public static FatValueEvidence CreateEvidence( + FatValueSlot slot, + FatLiveValueObservation observation) + => CreateEvidence(slot, observation, FatEvidenceCaptureKind.OperatorSnapshot); + /// /// Creates an immutable capture record without mutating a current evidence pointer. - /// Session controllers use this so the append-only journal can be durably written - /// before the replaceable Value 1 / Value 2 pointer is promoted. + /// OperatorRecapture is valid for every FAT signal type; AutomaticStableSnapshot is + /// reserved for conservative analog/other settling capture. /// public static FatValueEvidence CreateEvidence( FatValueSlot slot, - FatLiveValueObservation observation) + FatLiveValueObservation observation, + FatEvidenceCaptureKind captureKind) { ArgumentNullException.ThrowIfNull(observation); if (string.IsNullOrWhiteSpace(observation.RawValue) || @@ -50,10 +56,13 @@ public static FatValueEvidence CreateEvidence( throw new InvalidOperationException("The FAT signal does not have a readable live value to capture."); } + if (captureKind == FatEvidenceCaptureKind.AutomaticTransition) + throw new ArgumentOutOfRangeException(nameof(captureKind), "Transition evidence is created by the digital transition evaluator."); + return new FatValueEvidence( Guid.NewGuid(), slot, - FatEvidenceCaptureKind.OperatorSnapshot, + captureKind, observation.RawValue.Trim(), observation.CapturedAt, observation.IedTimestamp, diff --git a/Services/IoTesting/IoTestMultiSessionCoordinator.cs b/Services/IoTesting/IoTestMultiSessionCoordinator.cs index 491267a3f..917da760d 100644 --- a/Services/IoTesting/IoTestMultiSessionCoordinator.cs +++ b/Services/IoTesting/IoTestMultiSessionCoordinator.cs @@ -53,11 +53,6 @@ private IoTestSessionController? SelectedController public int ActiveSessionCount => ControllerSnapshot().Count(controller => controller.IsSessionActive); public bool HasActiveSessions => ActiveSessionCount > 0; - - // Session-facing properties are deliberately selected-context projections. This keeps - // existing FAT UI bindings intuitive: when IED A is running and the operator selects - // IED B, B still shows Start FAT and remains editable. Workspace-close/export code uses - // HasActiveSessions/ActiveSessionCount for the global safety boundary. public bool IsSelectedSessionActive => SelectedController?.IsSessionActive == true; public bool IsSessionActive => IsSelectedSessionActive; public bool CanStart => _selectedIed != null && !IsSelectedSessionActive; @@ -94,11 +89,6 @@ public string StatusText } } - /// - /// Supplies the MainWindow-owned leaf factory after the FAT window has an Owner. - /// The factory must create a fresh IoTestSessionController for the same project and - /// evidence directory; no controller instance may be shared between IEDs. - /// public void ConfigureSiblingFactory(Func factory) { ThrowIfDisposed(); @@ -202,11 +192,32 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, return result; } - /// - /// Runtime event route for additional P2 leaves only. MainWindow already routes every - /// event to PrimaryController, therefore including it here would duplicate primary - /// evidence. Each sibling leaf still enforces its own exact DeviceId boundary. - /// + public IoTestSessionActionResult RecaptureBatch( + IReadOnlyCollection points, + FatValueSlot slot) + { + ThrowIfDisposed(); + var controller = ResolveControllerForBatch(points, out var error); + if (controller == null) + return IoTestSessionActionResult.Failure(error); + + var result = controller.RecaptureBatch(points, slot, FatEvidenceCaptureKind.OperatorRecapture); + RaiseProjectionProperties(); + return result; + } + + public IoTestSessionActionResult BeginRecapturePair(IReadOnlyCollection points) + { + ThrowIfDisposed(); + var controller = ResolveControllerForBatch(points, out var error); + if (controller == null) + return IoTestSessionActionResult.Failure(error); + + var result = controller.BeginRecapturePair(points); + RaiseProjectionProperties(); + return result; + } + public void EnqueueAdditional(Iec61850EventEntry entry) { if (entry == null || _disposed) @@ -236,6 +247,34 @@ public void Dispose() _controllers.Clear(); } + private IoTestSessionController? ResolveControllerForBatch( + IReadOnlyCollection? points, + out string error) + { + if (points == null || points.Count == 0) + { + error = "Select one or more FAT rows first."; + return null; + } + + var distinct = points.Distinct().ToArray(); + var matches = ControllerSnapshot() + .Where(candidate => candidate.IsSessionActive && + candidate.ActiveIed != null && + distinct.All(point => candidate.ActiveIed.TestPoints.Contains(point))) + .ToArray(); + if (matches.Length == 1) + { + error = string.Empty; + return matches[0]; + } + + error = matches.Length == 0 + ? "All selected rows must belong to one active IED FAT session." + : "Selected rows resolved to more than one session controller; recapture was not started."; + return null; + } + private IoTestSessionController GetOrCreateController(IoTestIedPlan ied) { lock (_sync) diff --git a/Services/IoTesting/IoTestSessionController.P3Capture.cs b/Services/IoTesting/IoTestSessionController.P3Capture.cs new file mode 100644 index 000000000..bf92c2690 --- /dev/null +++ b/Services/IoTesting/IoTestSessionController.P3Capture.cs @@ -0,0 +1,276 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed partial class IoTestSessionController +{ + private readonly Dictionary _p3StableCapture = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _p3PendingPairPointIds = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Atomic manual recapture for one slot. All rows are preflighted first, all immutable + /// evidence records are appended as one journal batch, and only then are the replaceable + /// current V1/V2 pointers promoted. No partial pointer mutation is possible on failure. + /// OperatorRecapture is deliberately valid for automatic-transition and snapshot rows. + /// + public IoTestSessionActionResult RecaptureBatch( + IReadOnlyCollection points, + FatValueSlot slot, + FatEvidenceCaptureKind captureKind = FatEvidenceCaptureKind.OperatorRecapture) + { + ThrowIfDisposed(); + if (State != IoTestSessionState.Running || ActiveIed == null) + return IoTestSessionActionResult.Failure("Start the owning IED FAT session before recapturing Value 1 or Value 2."); + if (points == null || points.Count == 0) + return IoTestSessionActionResult.Failure("Select one or more FAT rows before recapturing evidence."); + if (captureKind is not (FatEvidenceCaptureKind.OperatorRecapture or FatEvidenceCaptureKind.OperatorSnapshot)) + return IoTestSessionActionResult.Failure("Manual capture requires OperatorSnapshot or OperatorRecapture provenance."); + + var distinct = points.Distinct().ToArray(); + var invalid = distinct.Where(point => + !ActiveIed.TestPoints.Contains(point) || + !_sessionPointIds.Contains(point.TestPointId) || + !point.WorkspaceSelected || + !point.IsIncludedInFat || + !point.TestEnabled || + !point.ImportReady || + !_activeLivePoints.ContainsKey(point.TestPointId) || + (captureKind == FatEvidenceCaptureKind.OperatorSnapshot && point.CaptureMode != FatCaptureMode.OperatorSnapshot)) + .ToArray(); + if (invalid.Length > 0) + { + return IoTestSessionActionResult.Failure( + $"{invalid.Length} of {distinct.Length} selected row(s) are not eligible/live in this IED session — no evidence changed."); + } + + var prepared = new List<(IoTestPointPlan Point, FatValueEvidence Evidence)>(distinct.Length); + try + { + foreach (var point in distinct) + { + var livePoint = _activeLivePoints[point.TestPointId]; + var observation = new FatLiveValueObservation( + livePoint.Value, + DateTimeOffset.UtcNow, + IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp), + livePoint.Quality, + livePoint.SourceMode, + livePoint.Sequence, + _connectionGeneration); + var evidence = FatOperatorSnapshotCaptureService.CreateEvidence(slot, observation, captureKind); + prepared.Add((point, evidence)); + } + + AppendBatchRequired(prepared.Select(item => FatValueEvent(item.Point, item.Evidence)).ToArray()); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) + { + return IoTestSessionActionResult.Failure(ex.Message); + } + + foreach (var item in prepared) + { + item.Point.Runtime.SetFatValueEvidence(item.Evidence); + item.Point.Runtime.StatusReason = + $"{(slot == FatValueSlot.Value1 ? "Value 1" : "Value 2")} manually recaptured; prior evidence remains in immutable history."; + } + + if (captureKind == FatEvidenceCaptureKind.OperatorRecapture) + { + foreach (var point in distinct) + _p3PendingPairPointIds.Remove(point.TestPointId); + } + + RaiseProgress(); + UpdateRunningCompletionStatus(); + var label = slot == FatValueSlot.Value1 ? "Value 1" : "Value 2"; + return IoTestSessionActionResult.Success( + $"{distinct.Length} signal(s) · {label} recaptured · {ActiveIed.IedName} · {DateTime.Now:HH:mm:ss.fff}"); + } + + /// + /// Arms a two-condition recapture without replacing either current pointer. The operator + /// captures V1 under condition 1, changes the process condition, then captures V2. The + /// existing pair remains current until those explicit slot actions occur. + /// + public IoTestSessionActionResult BeginRecapturePair(IReadOnlyCollection points) + { + ThrowIfDisposed(); + if (State != IoTestSessionState.Running || ActiveIed == null) + return IoTestSessionActionResult.Failure("Start the owning IED FAT session before re-arming Value 1 & Value 2."); + if (points == null || points.Count == 0) + return IoTestSessionActionResult.Failure("Select one or more FAT rows before re-arming Value 1 & Value 2."); + + var distinct = points.Distinct().ToArray(); + var invalid = distinct.Where(point => + !ActiveIed.TestPoints.Contains(point) || + !_sessionPointIds.Contains(point.TestPointId) || + !point.WorkspaceSelected || + !point.IsIncludedInFat || + !point.TestEnabled || + !point.ImportReady || + !_activeLivePoints.ContainsKey(point.TestPointId)) + .ToArray(); + if (invalid.Length > 0) + return IoTestSessionActionResult.Failure($"{invalid.Length} of {distinct.Length} selected row(s) are not live/eligible — recapture was not armed."); + + _p3PendingPairPointIds.Clear(); + foreach (var point in distinct) + _p3PendingPairPointIds.Add(point.TestPointId); + + if (!AppendOrFault(SessionEvent( + "operator_pair_recapture_armed", + $"Operator armed two-condition Value 1 & Value 2 recapture for {distinct.Length} selected signal(s). Existing current evidence remains authoritative until explicit slot captures."))) + { + _p3PendingPairPointIds.Clear(); + return IoTestSessionActionResult.Failure(StatusText); + } + + return IoTestSessionActionResult.Success( + $"{distinct.Length} signal(s) armed · capture Value 1 under condition 1, then Value 2 after the condition changes."); + } + + private bool ObserveP3AutoCapture(IoTestPointPlan point, QueuedSnapshot snapshot) + { + if (point.CaptureMode != FatCaptureMode.OperatorSnapshot || + !_sessionPointIds.Contains(point.TestPointId) || + !point.WorkspaceSelected || + !point.IsIncludedInFat || + !point.TestEnabled || + !point.ImportReady || + !IsP3TrustworthyQuality(snapshot.Quality) || + string.IsNullOrWhiteSpace(snapshot.Value) || + snapshot.Value.Trim() is "-" or "—") + { + return false; + } + + // Explicit operator recapture pins a slot: automatic settling must never silently + // replace a value the operator deliberately chose. + if (point.Runtime.Value1Evidence?.CaptureKind == FatEvidenceCaptureKind.OperatorRecapture && + point.Runtime.Value2Evidence?.CaptureKind == FatEvidenceCaptureKind.OperatorRecapture) + { + return false; + } + + if (!_p3StableCapture.TryGetValue(point.TestPointId, out var state)) + { + state = new P3StableCaptureState(); + _p3StableCapture[point.TestPointId] = state; + } + + var raw = snapshot.Value.Trim(); + if (point.Runtime.Value1Evidence == null) + { + if (!state.Observe(raw)) + { + point.Runtime.StatusReason = "Waiting for stable Value 1 baseline…"; + return false; + } + + return CommitAutomaticStableValue(point, FatValueSlot.Value1, snapshot, state); + } + + if (point.Runtime.Value2Evidence != null) + return false; + + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(point.Runtime.Value1Evidence.RawValue, raw)) + { + state.Reset(); + point.Runtime.StatusReason = "Value 1 captured · waiting for a meaningful value change"; + return false; + } + + if (!state.Observe(raw)) + { + point.Runtime.StatusReason = "Value changed · stabilizing Value 2…"; + return false; + } + + return CommitAutomaticStableValue(point, FatValueSlot.Value2, snapshot, state); + } + + private bool CommitAutomaticStableValue( + IoTestPointPlan point, + FatValueSlot slot, + QueuedSnapshot snapshot, + P3StableCaptureState state) + { + try + { + var observation = new FatLiveValueObservation( + snapshot.Value, + snapshot.CapturedAtUtc, + IoTestValueNormalizer.ParseIedTimestamp(snapshot.DeviceTimestamp), + snapshot.Quality, + snapshot.SourceMode, + snapshot.Sequence, + _connectionGeneration); + var evidence = FatOperatorSnapshotCaptureService.CreateEvidence( + slot, + observation, + FatEvidenceCaptureKind.AutomaticStableSnapshot); + if (!AppendOrFault(FatValueEvent(point, evidence))) + return false; + + point.Runtime.SetFatValueEvidence(evidence); + point.Runtime.StatusReason = slot == FatValueSlot.Value1 + ? "Value 1 stable baseline captured · waiting for a meaningful value change" + : "Value 1 / Value 2 stable evidence complete"; + state.Reset(); + return true; + } + catch (InvalidOperationException) + { + state.Reset(); + return false; + } + } + + private void ResetP3AutoCaptureState() + { + _p3StableCapture.Clear(); + _p3PendingPairPointIds.Clear(); + } + + private static bool IsP3TrustworthyQuality(string? quality) + { + if (string.IsNullOrWhiteSpace(quality)) + return true; + var value = quality.Trim(); + return !value.Contains("bad", StringComparison.OrdinalIgnoreCase) && + !value.Contains("invalid", StringComparison.OrdinalIgnoreCase) && + !value.Contains("questionable", StringComparison.OrdinalIgnoreCase) && + !value.Contains("failure", StringComparison.OrdinalIgnoreCase) && + !value.Contains("oldData", StringComparison.OrdinalIgnoreCase); + } + + private sealed class P3StableCaptureState + { + private string _candidate = string.Empty; + private int _equivalentCount; + + // Two consecutive semantically equivalent observations is intentionally a + // conservative, amplitude-free settling rule. It rejects transient ramp samples + // without inventing a vendor-specific analog tolerance. + public bool Observe(string raw) + { + if (_candidate.Length > 0 && Iec61850MonitorPoint.AreSemanticallyEquivalent(_candidate, raw)) + { + _equivalentCount++; + } + else + { + _candidate = raw; + _equivalentCount = 1; + } + return _equivalentCount >= 2; + } + + public void Reset() + { + _candidate = string.Empty; + _equivalentCount = 0; + } + } +} diff --git a/Services/IoTesting/IoTestSessionController.cs b/Services/IoTesting/IoTestSessionController.cs index 26fcf82bf..fee9b9904 100644 --- a/Services/IoTesting/IoTestSessionController.cs +++ b/Services/IoTesting/IoTestSessionController.cs @@ -7,7 +7,7 @@ namespace ArIED61850Tester.Services.IoTesting; -public sealed class IoTestSessionController : ObservableObject, IDisposable +public sealed partial class IoTestSessionController : ObservableObject, IDisposable { private const int MaxSnapshotsPerDrain = 64; private const int DrainBudgetMilliseconds = 4; @@ -22,12 +22,7 @@ public sealed class IoTestSessionController : ObservableObject, IDisposable private readonly Func _journalFactory; private readonly IoTestTransitionEvaluator _evaluator; private readonly IoTestRollingCaptureCoordinator _captureCoordinator; - // Only the newest observation per live point is needed by the UI transition - // evaluator. Coalescing prevents a fast report/poll stream from building an - // unbounded dispatcher backlog while the operator scrolls the FAT grid. private readonly ConcurrentDictionary _pendingSnapshots = new(StringComparer.OrdinalIgnoreCase); - // Actual transitions are never coalesced: FAT evidence must retain a fast - // OFF→ON→OFF sequence even when all three samples arrive in one UI frame. private readonly ConcurrentQueue _pendingEdgeSnapshots = new(); private readonly Dictionary> _activePointIndex = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _activeLivePoints = new(StringComparer.OrdinalIgnoreCase); @@ -134,20 +129,20 @@ public IoTestSessionActionResult Start( if (captureScope == null) { expectedPoints = ied.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady) + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady) .ToList(); } else { var knownPoints = new HashSet(ied.TestPoints); var invalidScope = captureScope - .Where(point => !knownPoints.Contains(point) || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) + .Where(point => !knownPoints.Contains(point) || !point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) .Distinct() .ToList(); if (invalidScope.Count > 0) { return IoTestSessionActionResult.Failure( - "The requested FAT capture scope contains a row that is not part of this IED, was removed by the operator, is not selected, or is not import-ready."); + "The requested FAT capture scope contains a row that is not part of this IED, is outside the shared workspace, was removed by the operator, is not selected, or is not import-ready."); } expectedPoints = captureScope.Distinct().ToList(); @@ -177,6 +172,7 @@ public IoTestSessionActionResult Start( _activeLivePoints.Clear(); _sessionPointIds.Clear(); ClearPendingSnapshots(); + ResetP3AutoCaptureState(); foreach (var binding in bindings) { @@ -211,7 +207,7 @@ public IoTestSessionActionResult Start( binding.Point, observation with { NormalizedState = null }, null, - "Live value established for operator Value 1 / Value 2 snapshot capture.")); + "Live value established for automatic stable Value 1 / Value 2 capture and manual override.")); continue; } @@ -230,7 +226,7 @@ public IoTestSessionActionResult Start( } State = IoTestSessionState.Running; - StatusText = $"FAT capture running for {ied.IedName}. Automatic rows remain armed; operator-snapshot rows expose live values for Value 1 / Value 2 capture until Stop."; + StatusText = $"FAT capture running for {ied.IedName}. Digital rows track transitions; analog/other rows auto-latch stable Value 1 / Value 2 and remain manually recapturable until Stop."; RaiseProgress(); UpdateRunningCompletionStatus(); return IoTestSessionActionResult.Success(StatusText); @@ -277,6 +273,7 @@ public IoTestSessionActionResult Resume() _connectionGeneration++; ClearPendingSnapshots(); + ResetP3AutoCaptureState(); if (!AppendOrFault(SessionEvent("session_resumed", "Session resumed after rebinding current live points; current values are treated as a new baseline image."))) return IoTestSessionActionResult.Failure(StatusText); @@ -350,49 +347,9 @@ public IoTestSessionActionResult ResetForCleanRetest() public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, FatValueSlot slot) { ThrowIfDisposed(); - if (State != IoTestSessionState.Running || ActiveIed == null) - return IoTestSessionActionResult.Failure("Start the IED FAT session before capturing Value 1 or Value 2."); - if (point == null || !ActiveIed.TestPoints.Contains(point)) - return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active IED session."); - if (!_sessionPointIds.Contains(point.TestPointId)) - return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active capture scope."); - if (!point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) - return IoTestSessionActionResult.Failure("The FAT row must remain included, operator-selected, and import-ready to capture evidence."); - if (point.CaptureMode != FatCaptureMode.OperatorSnapshot) - return IoTestSessionActionResult.Failure("This FAT row uses automatic transition capture; Value 1 / Value 2 are captured by live transitions."); - if (!_activeLivePoints.TryGetValue(point.TestPointId, out var livePoint)) - return IoTestSessionActionResult.Failure("The FAT row does not have one active live point to snapshot."); - - try - { - var observation = new FatLiveValueObservation( - livePoint.Value, - DateTimeOffset.UtcNow, - IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp), - livePoint.Quality, - livePoint.SourceMode, - livePoint.Sequence, - _connectionGeneration); - var evidence = FatOperatorSnapshotCaptureService.CreateEvidence(slot, observation); - - // Audit history is authoritative and append-only. Only after the journal write - // succeeds may the replaceable current Value 1 / Value 2 pointer be promoted. - if (!AppendOrFault(FatValueEvent(point, evidence))) - return IoTestSessionActionResult.Failure(StatusText); - - point.Runtime.SetFatValueEvidence(evidence); - point.Runtime.StatusReason = point.IsFatEvidenceComplete - ? "Value 1 and Value 2 captured. Current evidence is complete; either value may be recaptured while the session remains running." - : $"{(slot == FatValueSlot.Value1 ? "Value 1" : "Value 2")} captured; capture the remaining value to complete current evidence."; - RaiseProgress(); - UpdateRunningCompletionStatus(); - return IoTestSessionActionResult.Success( - $"{point.SignalName}: {(slot == FatValueSlot.Value1 ? "Value 1" : "Value 2")} captured as '{evidence.RawValue}'."); - } - catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) - { - return IoTestSessionActionResult.Failure(ex.Message); - } + if (point == null) + return IoTestSessionActionResult.Failure("Select a FAT row before capturing Value 1 or Value 2."); + return RecaptureBatch(new[] { point }, slot, FatEvidenceCaptureKind.OperatorSnapshot); } public void Enqueue(Iec61850EventEntry entry) @@ -510,6 +467,8 @@ private void ProcessSnapshot(QueuedSnapshot snapshot) var progressChanged = false; foreach (var point in points.Distinct()) { + progressChanged |= ObserveP3AutoCapture(point, snapshot); + if (point.CaptureMode == FatCaptureMode.OperatorSnapshot) { point.Runtime.ApplyObservation(new IoTestObservation( @@ -629,7 +588,7 @@ private static List BuildSessionBindings( Iec61850MonitorDevice device) { var result = new List(); - foreach (var point in captureScope.Where(point => point.IsIncludedInFat && point.IsLiveBound)) + foreach (var point in captureScope.Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.IsLiveBound)) { var expected = IoTestLiveBindingService.NormalizeReference( string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference); @@ -745,7 +704,8 @@ private IoTestJournalEntry PointEvent( private IoTestJournalEntry FatValueEvent(IoTestPointPlan point, FatValueEvidence evidence) => new() { - EventType = "fat_value_snapshot", + EventType = evidence.CaptureKind == FatEvidenceCaptureKind.OperatorRecapture ? "fat_value_recapture" : + evidence.CaptureKind == FatEvidenceCaptureKind.AutomaticStableSnapshot ? "fat_value_auto_stable" : "fat_value_snapshot", RecordedAtUtc = evidence.CapturedAt, ProjectId = _project.ProjectId, SessionId = SessionId, @@ -768,8 +728,13 @@ private IoTestJournalEntry PointEvent( PointSequence = evidence.Sequence, ConnectionGeneration = evidence.ConnectionGeneration, Verdict = "Accepted", - Reason = $"Operator captured {evidence.Slot} from the current live IEC 61850 value.", - EvidenceKind = "fat-value-snapshot" + Reason = evidence.CaptureKind switch + { + FatEvidenceCaptureKind.OperatorRecapture => $"Operator recaptured {evidence.Slot} from the current live IEC 61850 value.", + FatEvidenceCaptureKind.AutomaticStableSnapshot => $"Automatic stable capture accepted {evidence.Slot} after consecutive equivalent live observations.", + _ => $"Operator captured {evidence.Slot} from the current live IEC 61850 value." + }, + EvidenceKind = evidence.CaptureKind.ToString() }; private void AppendRequired(IoTestJournalEntry entry) @@ -848,6 +813,7 @@ private void CleanupSessionResources(bool disposeJournal, bool keepActiveIed = f _activePointIndex.Clear(); _activeLivePoints.Clear(); _captureCoordinator.Clear(); + ResetP3AutoCaptureState(); ClearPendingSnapshots(); if (disposeJournal) {