Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 116 additions & 22 deletions IoListTestingWindow.FatV2Ux.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -40,6 +40,10 @@ private void InstallFatV2WorkspaceUx()
_fatSignalsGrid = FindVisualDescendant<DataGrid>(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();
Expand All @@ -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));

Expand All @@ -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;
Expand Down Expand Up @@ -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")
{
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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);

Expand All @@ -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<IoTestPointPlan> SelectedFatPoints()
{
if (_fatSignalsGrid == null)
return Array.Empty<IoTestPointPlan>();
return _fatSignalsGrid.SelectedItems
.OfType<IoTestPointPlan>()
.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<DataGridRow>(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))
{
Expand Down Expand Up @@ -413,6 +471,42 @@ private static IEnumerable<T> FindVisualDescendants<T>(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)
Expand Down
4 changes: 3 additions & 1 deletion Models/IoTesting/FatVerificationModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ public enum FatValueSlot
public enum FatEvidenceCaptureKind
{
AutomaticTransition,
OperatorSnapshot
AutomaticStableSnapshot,
OperatorSnapshot,
OperatorRecapture
}

public sealed record FatValueEvidence(
Expand Down
25 changes: 17 additions & 8 deletions Services/IoTesting/FatOperatorSnapshotCaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace ArIED61850Tester.Services.IoTesting;

/// <summary>
/// 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.
/// </summary>
public static class FatOperatorSnapshotCaptureService
{
Expand All @@ -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);

/// <summary>
/// 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.
/// </summary>
public static FatValueEvidence CreateEvidence(
FatValueSlot slot,
FatLiveValueObservation observation)
FatLiveValueObservation observation,
FatEvidenceCaptureKind captureKind)
{
ArgumentNullException.ThrowIfNull(observation);
if (string.IsNullOrWhiteSpace(observation.RawValue) ||
Expand All @@ -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,
Expand Down
69 changes: 54 additions & 15 deletions Services/IoTesting/IoTestMultiSessionCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,11 +89,6 @@ public string StatusText
}
}

/// <summary>
/// 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.
/// </summary>
public void ConfigureSiblingFactory(Func<IoTestSessionController> factory)
{
ThrowIfDisposed();
Expand Down Expand Up @@ -202,11 +192,32 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point,
return result;
}

/// <summary>
/// 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.
/// </summary>
public IoTestSessionActionResult RecaptureBatch(
IReadOnlyCollection<IoTestPointPlan> 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<IoTestPointPlan> 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)
Expand Down Expand Up @@ -236,6 +247,34 @@ public void Dispose()
_controllers.Clear();
}

private IoTestSessionController? ResolveControllerForBatch(
IReadOnlyCollection<IoTestPointPlan>? 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)
Expand Down
Loading