diff --git a/IntegratedFatWorkspaceControl.xaml b/IntegratedFatWorkspaceControl.xaml
new file mode 100644
index 000000000..ca3c692f6
--- /dev/null
+++ b/IntegratedFatWorkspaceControl.xaml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/IntegratedFatWorkspaceControl.xaml.cs b/IntegratedFatWorkspaceControl.xaml.cs
new file mode 100644
index 000000000..63b6df0cb
--- /dev/null
+++ b/IntegratedFatWorkspaceControl.xaml.cs
@@ -0,0 +1,237 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Input;
+using System.Windows.Threading;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ArIED61850Tester;
+
+public partial class IntegratedFatWorkspaceControl : UserControl, INotifyPropertyChanged, IDisposable
+{
+ private readonly Action _selectionChanged;
+ private IoTestIedPlan? _selectedIed;
+ private Iec61850MonitorDevice? _selectedEngineeringIed;
+ private string _statusText = "Select an IED and start FAT when its Engineering monitor is live.";
+ private bool _disposed;
+
+ public IntegratedFatWorkspaceControl(
+ IoTestWorkspaceLaunchResult launch,
+ IEnumerable engineeringIeds,
+ Action selectionChanged)
+ {
+ InitializeComponent();
+ Project = launch.Project;
+ Session = launch.Session;
+ Storage = launch.Workspace;
+ EngineeringIeds = engineeringIeds;
+ _selectionChanged = selectionChanged;
+ Session.PropertyChanged += Session_PropertyChanged;
+ DataContext = this;
+ SelectedEngineeringIed = EngineeringIeds.FirstOrDefault();
+ SelectedIed ??= Project.Ieds.FirstOrDefault();
+ }
+
+ public IoTestProject Project { get; }
+ public IoTestSessionController Session { get; }
+ public IoTestWorkspacePersistence Storage { get; }
+ public IEnumerable EngineeringIeds { get; }
+
+ public Iec61850MonitorDevice? SelectedEngineeringIed
+ {
+ get => _selectedEngineeringIed;
+ set
+ {
+ if (ReferenceEquals(_selectedEngineeringIed, value)) return;
+ _selectedEngineeringIed = value;
+ Raise(nameof(SelectedEngineeringIed));
+ SelectedIed = ResolveFatIed(value);
+ _selectionChanged(value);
+ }
+ }
+
+ public IoTestIedPlan? SelectedIed
+ {
+ get => _selectedIed;
+ set
+ {
+ if (ReferenceEquals(_selectedIed, value)) return;
+ _selectedIed = value;
+ Raise(nameof(SelectedIed));
+ ApplyPointScope();
+ if (PreviewView.Visibility == Visibility.Visible) RefreshPreview();
+ }
+ }
+
+ public string StatusText
+ {
+ get => _statusText;
+ private set
+ {
+ if (_statusText == value) return;
+ _statusText = value;
+ Raise(nameof(StatusText));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void SelectEngineeringIed(string? deviceId, string? name, string? ipAddress)
+ {
+ if (!Session.CanSelectIed)
+ return;
+
+ var match = Project.Ieds.FirstOrDefault(ied =>
+ !string.IsNullOrWhiteSpace(deviceId) &&
+ ied.LiveDeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase))
+ ?? Project.Ieds.FirstOrDefault(ied =>
+ ied.IedName.Equals(name, StringComparison.OrdinalIgnoreCase) &&
+ (string.IsNullOrWhiteSpace(ipAddress) ||
+ ied.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)));
+ var engineeringMatch = EngineeringIeds.FirstOrDefault(ied =>
+ !string.IsNullOrWhiteSpace(deviceId) &&
+ ied.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase))
+ ?? EngineeringIeds.FirstOrDefault(ied =>
+ ied.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
+ (string.IsNullOrWhiteSpace(ipAddress) ||
+ ied.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)));
+ if (engineeringMatch != null)
+ SelectedEngineeringIed = engineeringMatch;
+ else if (match != null)
+ SelectedIed = match;
+ }
+
+ private IoTestIedPlan? ResolveFatIed(Iec61850MonitorDevice? device)
+ => device == null
+ ? null
+ : Project.Ieds.FirstOrDefault(ied =>
+ ied.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase))
+ ?? Project.Ieds.FirstOrDefault(ied =>
+ ied.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase) &&
+ ied.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase));
+
+ private void ApplyPointScope()
+ {
+ if (SelectedIed == null) return;
+ var view = CollectionViewSource.GetDefaultView(SelectedIed.TestPoints);
+ view.Filter = item => item is IoTestPointPlan point &&
+ point.WorkspaceSelected && point.IsIncludedInFat;
+ view.Refresh();
+ }
+
+ private void StartFat_Click(object sender, RoutedEventArgs e)
+ => ShowResult(Session.Start(SelectedIed));
+
+ private void StopFat_Click(object sender, RoutedEventArgs e)
+ => ShowResult(Session.Stop("Stopped from integrated FAT tab."));
+
+ private void CaptureValue1_Click(object sender, RoutedEventArgs e)
+ => CaptureSelected(FatValueSlot.Value1);
+
+ private void CaptureValue2_Click(object sender, RoutedEventArgs e)
+ => CaptureSelected(FatValueSlot.Value2);
+
+ private void CaptureSelected(FatValueSlot slot)
+ {
+ var points = FatSignalsGrid.SelectedItems.OfType().ToArray();
+ if (points.Length == 0 && FatSignalsGrid.SelectedItem is IoTestPointPlan current)
+ points = new[] { current };
+ if (points.Length == 0)
+ {
+ StatusText = "Select at least one FAT row first.";
+ return;
+ }
+
+ var failures = new List();
+ foreach (var point in points)
+ {
+ var result = Session.CaptureOperatorSnapshot(point, slot);
+ if (!result.Succeeded)
+ failures.Add($"{point.DisplaySignalName}: {result.Message}");
+ }
+ Storage.ScheduleSave();
+ StatusText = failures.Count == 0
+ ? $"{slot}: captured for {points.Length} selected signal(s)."
+ : string.Join(" • ", failures.Take(3));
+ }
+
+ private void ShowResult(IoTestSessionActionResult result)
+ {
+ StatusText = result.Message;
+ if (result.Succeeded)
+ Storage.ScheduleSave();
+ }
+
+ private void OpenPreview_Click(object sender, RoutedEventArgs e)
+ {
+ if (SelectedIed == null)
+ {
+ StatusText = "Select an IED before opening Print Preview.";
+ return;
+ }
+ WorkspaceView.Visibility = Visibility.Collapsed;
+ PreviewView.Visibility = Visibility.Visible;
+ RefreshPreview();
+ }
+
+ private void ClosePreview_Click(object sender, RoutedEventArgs e)
+ {
+ PreviewView.Visibility = Visibility.Collapsed;
+ WorkspaceView.Visibility = Visibility.Visible;
+ }
+
+ private void RefreshPreview_Click(object sender, RoutedEventArgs e)
+ => RefreshPreview();
+
+ private void RefreshPreview()
+ {
+ if (SelectedIed == null)
+ return;
+ try
+ {
+ var scoped = IoFatReportPreviewService.CreateIedScopedProject(Project, SelectedIed);
+ var draft = Session.IsSessionActive && ReferenceEquals(Session.ActiveIed, SelectedIed);
+ PreviewDocument.Document = IoFatReportPreviewDocumentBuilder.Build(scoped, draft);
+ PreviewDocument.UpdateLayout();
+ Dispatcher.BeginInvoke(new Action(PreviewDocument.FitToWidth), DispatcherPriority.Loaded);
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Print Preview could not be rendered: {ex.Message}";
+ }
+ }
+
+ private void PrintPreview_Click(object sender, RoutedEventArgs e)
+ {
+ if (PreviewDocument.Document != null &&
+ ApplicationCommands.Print.CanExecute(null, PreviewDocument))
+ {
+ ApplicationCommands.Print.Execute(null, PreviewDocument);
+ }
+ }
+
+ private void Session_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ Raise(nameof(Session));
+ if (PreviewView.Visibility == Visibility.Visible &&
+ e.PropertyName is nameof(IoTestSessionController.State) or
+ nameof(IoTestSessionController.EvidenceRecordCount))
+ {
+ Dispatcher.BeginInvoke(new Action(RefreshPreview), DispatcherPriority.Background);
+ }
+ }
+
+ private void Raise(string propertyName)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Session.PropertyChanged -= Session_PropertyChanged;
+ Storage.ScheduleSave();
+ }
+}
diff --git a/MainWindow.IntegratedFatTab.cs b/MainWindow.IntegratedFatTab.cs
new file mode 100644
index 000000000..8e100fd3c
--- /dev/null
+++ b/MainWindow.IntegratedFatTab.cs
@@ -0,0 +1,101 @@
+using System.Windows;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ArIED61850Tester;
+
+public partial class MainWindow
+{
+ private IntegratedFatWorkspaceControl? _integratedFatWorkspace;
+ private IoTestWorkspaceLaunchResult? _integratedFatLaunch;
+
+ private async void OpenIntegratedFatFromCurrentScl_Click(object sender, RoutedEventArgs e)
+ {
+ if (_integratedFatWorkspace != null)
+ {
+ MainTabs.SelectedIndex = 6;
+ return;
+ }
+
+ var sources = CurrentEngineeringSclSourcePaths();
+ if (sources.Length == 0)
+ {
+ SetStatus("Open an SCL in IEC 61850 Explorer before entering FAT.");
+ MessageBox.Show(
+ this,
+ "The integrated FAT tab uses only the SCL currently open in Engineering. Open an SCL first.",
+ "No active Engineering SCL",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ MainTabs.SelectedIndex = 0;
+ return;
+ }
+
+ await OpenSclFatSourcesAsync(sources, selectionMode: null);
+ }
+
+ private void ShowIntegratedFatWorkspace(IoTestWorkspaceLaunchResult launch)
+ {
+ _integratedFatLaunch = launch;
+ _activeIoTestSessionController = launch.Session;
+ Interlocked.Exchange(ref _ioTestObservationSequence, DateTime.UtcNow.Ticks);
+ _runtime.PointUpdated += Runtime_IoTestPointUpdated;
+
+ var engineeringIeds = Devices.Where(device => launch.Project.Ieds.Any(ied =>
+ ied.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) ||
+ (ied.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase) &&
+ ied.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase))));
+
+ _integratedFatWorkspace = new IntegratedFatWorkspaceControl(
+ launch,
+ engineeringIeds,
+ selected => SynchronizeIntegratedFatSelection(selected));
+ IntegratedFatHost.Children.Clear();
+ IntegratedFatHost.Children.Add(_integratedFatWorkspace);
+ if (SelectedDevice != null)
+ {
+ _integratedFatWorkspace.SelectEngineeringIed(
+ SelectedDevice.DeviceId,
+ SelectedDevice.Name,
+ SelectedDevice.IpAddress);
+ }
+
+ MainTabs.SelectedIndex = 6;
+ SetStatus($"Integrated FAT ready · {launch.Project.Ieds.Count} IED · shared Engineering live process image.");
+ UpdateIoFatWorkspaceModeState();
+ }
+
+ private void SynchronizeIntegratedFatSelection(Iec61850MonitorDevice? selected)
+ {
+ if (selected == null) return;
+ if (!ReferenceEquals(SelectedDevice, selected))
+ SelectedDevice = selected;
+ }
+
+ private void DisposeIntegratedFatWorkspace()
+ {
+ var launch = _integratedFatLaunch;
+ if (launch == null) return;
+
+ _runtime.PointUpdated -= Runtime_IoTestPointUpdated;
+ if (launch.Session.IsSessionActive)
+ launch.Session.Stop("Integrated FAT workspace closed.");
+ _integratedFatWorkspace?.Dispose();
+ DetachIoFatSelectionBridge(launch.Project);
+ launch.Session.Dispose();
+ launch.Workspace.Dispose();
+ if (ReferenceEquals(_activeIoTestSessionController, launch.Session))
+ _activeIoTestSessionController = null;
+
+ _integratedFatWorkspace = null;
+ _integratedFatLaunch = null;
+ if (IntegratedFatHost != null)
+ IntegratedFatHost.Children.Clear();
+ if (_pollingIntervalBeforeIoFat.HasValue)
+ {
+ PollingIntervalMs = _pollingIntervalBeforeIoFat.Value;
+ _pollingIntervalBeforeIoFat = null;
+ }
+ }
+}
diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs
index 52f1cfc91..bda203978 100644
--- a/MainWindow.IoTesting.cs
+++ b/MainWindow.IoTesting.cs
@@ -471,6 +471,7 @@ private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e)
private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int importWarningCount)
{
+ DisposeIntegratedFatWorkspace();
AttachIoFatSelectionBridge(launch.Project);
var binding = _ioTestLiveBindingService.Bind(launch.Project, Devices);
var restoredText = launch.RestoredProgress ? "saved progress restored" : "new project";
@@ -491,6 +492,14 @@ private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int
_pollingIntervalBeforeIoFat ??= PollingIntervalMs;
PollingIntervalMs = Math.Min(PollingIntervalMs, IoFatPollingIntervalMs);
+ if (launch.Project.Sources.Count > 0 &&
+ launch.Project.Sources.All(source =>
+ source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)))
+ {
+ ShowIntegratedFatWorkspace(launch);
+ return Task.CompletedTask;
+ }
+
var controller = launch.Session;
var persistence = launch.Workspace;
var window = new IoListTestingWindow(launch.Project, controller, persistence) { Owner = this };
diff --git a/MainWindow.NavigationLayoutFix.cs b/MainWindow.NavigationLayoutFix.cs
index fe2e8325f..9010f108a 100644
--- a/MainWindow.NavigationLayoutFix.cs
+++ b/MainWindow.NavigationLayoutFix.cs
@@ -10,11 +10,11 @@ namespace ArIED61850Tester;
///
/// Owns the responsive geometry of the MainWindow workflow header.
///
-/// The original XAML used a 760 px shell split into six equal columns while the
+/// The original XAML used a 760 px shell split into equal columns while the
/// selection pill moved in hard-coded 150 px steps. That was barely large enough for
/// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once the center
/// workspace switch and live connection/status chips were also present. This behavior
-/// keeps the header single-line at normal desktop sizes, deliberately compacts labels
+/// keeps all seven destinations on one line at normal desktop sizes, deliberately compacts labels
/// at smaller widths, and derives the selection pill from the real nav cell width.
///
internal static class MainWindowNavigationLayoutFix
@@ -31,7 +31,9 @@ internal static class MainWindowNavigationLayoutFix
"Live Monitor",
"Event Log",
"Alarm",
- "GOOSE Subscriber"
+ "GOOSE Subscriber",
+ "Diagnostics",
+ "FAT"
];
private static readonly string[] CompactLabels =
@@ -40,7 +42,9 @@ internal static class MainWindowNavigationLayoutFix
"Live",
"Events",
"Alarm",
- "GOOSE"
+ "GOOSE",
+ "Diagnostics",
+ "FAT"
];
[ModuleInitializer]
@@ -93,7 +97,7 @@ private static void OnMainWindowButtonClick(object sender, RoutedEventArgs e)
if (sender is not MainWindow window || e.Source is not Button button)
return;
- if (button.Name is not ("NavExplorerButton" or "NavLiveButton" or "NavEventsButton" or "NavAlarmButton" or "NavGooseButton" or "NavDiagnosticsButton"))
+ if (button.Name is not ("NavExplorerButton" or "NavLiveButton" or "NavEventsButton" or "NavAlarmButton" or "NavGooseButton" or "NavDiagnosticsButton" or "NavFatButton"))
return;
// A repeated click on the already-selected tab does not raise SelectionChanged,
@@ -169,7 +173,10 @@ private static void ApplyResponsiveLayout(MainWindow window)
// Diagnostics owns a StackPanel containing its text plus the red alert
// badge. Never replace that Content tree while changing layout density.
if (index < labels.Length)
- button.Content = labels[index];
+ {
+ if (index != 5)
+ button.Content = labels[index];
+ }
button.MinHeight = 40;
button.MinWidth = 0;
@@ -195,7 +202,8 @@ private static void ApplyResponsiveLayout(MainWindow window)
window.FindName("NavEventsButton") as Button,
window.FindName("NavAlarmButton") as Button,
window.FindName("NavGooseButton") as Button,
- window.FindName("NavDiagnosticsButton") as Button
+ window.FindName("NavDiagnosticsButton") as Button,
+ window.FindName("NavFatButton") as Button
];
private static void UpdatePillGeometry(MainWindow window, double shellWidth)
@@ -204,9 +212,9 @@ private static void UpdatePillGeometry(MainWindow window, double shellWidth)
return;
// Border padding owns 10 px horizontally. The nav grid itself is divided into
- // six equal star columns, so this is the exact width used by each button cell.
+ // seven equal star columns, so this is the exact width used by each button cell.
var contentWidth = Math.Max(0d, shellWidth - 10d);
- var cellWidth = contentWidth / 6d;
+ var cellWidth = contentWidth / 7d;
pill.Width = Math.Max(1d, cellWidth - 2d);
pill.Height = 36;
pill.HorizontalAlignment = HorizontalAlignment.Left;
@@ -279,8 +287,8 @@ private static void PositionPill(MainWindow window, bool animate)
if (contentWidth <= 0d)
contentWidth = Math.Max(0d, shell.Width - shell.Padding.Left - shell.Padding.Right);
- var cellWidth = contentWidth / 6d;
- var target = Math.Clamp(tabs.SelectedIndex, 0, 5) * cellWidth;
+ var cellWidth = contentWidth / 7d;
+ var target = Math.Clamp(tabs.SelectedIndex, 0, 6) * cellWidth;
pill.Width = Math.Max(1d, cellWidth - 2d);
translate.BeginAnimation(TranslateTransform.XProperty, null);
diff --git a/MainWindow.WorkspaceModeSwitch.cs b/MainWindow.WorkspaceModeSwitch.cs
index 71983c289..20d3551fb 100644
--- a/MainWindow.WorkspaceModeSwitch.cs
+++ b/MainWindow.WorkspaceModeSwitch.cs
@@ -138,6 +138,11 @@ internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window)
private async void OpenOrResumeIoFatWorkspace_Click(object sender, RoutedEventArgs e)
{
+ if (_integratedFatWorkspace != null)
+ {
+ MainTabs.SelectedIndex = 6;
+ return;
+ }
if (ShowLoadedIoFatWorkspace())
return;
@@ -285,7 +290,7 @@ private void UpdateIoFatWorkspaceModeState()
if (_workspaceFatButton == null)
return;
- var loaded = _loadedIoFatWindow is { IsLoaded: true };
+ var loaded = _integratedFatWorkspace != null || _loadedIoFatWindow is { IsLoaded: true };
_workspaceFatButton.Content = loaded ? "IO LIST FAT · LOADED" : "IO LIST FAT";
_workspaceFatButton.ToolTip = loaded
? "Return instantly to the loaded IO List FAT workspace"
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 205519e97..196534a1d 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -121,8 +121,9 @@
+
-
@@ -151,12 +152,14 @@
+
+ and reserves width for the seven workflow destinations. -->
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 4f55a7528..6d6ea08e5 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -89,6 +89,10 @@ public Iec61850MonitorDevice? SelectedDevice
RaiseWorkspaceCounts();
TryAutoExpandCommandPanelOnce(_selectedDevice);
ApplyExplorerLiveSearchFilter();
+ _integratedFatWorkspace?.SelectEngineeringIed(
+ _selectedDevice?.DeviceId,
+ _selectedDevice?.Name,
+ _selectedDevice?.IpAddress);
// ctlModel inspection is preloaded independently of the Expander. Avoid
// changing the row set after the panel's first frame has already painted.
}
@@ -620,7 +624,7 @@ private void NavButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || !int.TryParse(button.Tag?.ToString(), out var index))
return;
- index = Math.Clamp(index, 0, 5);
+ index = Math.Clamp(index, 0, 6);
MainTabs.SelectedIndex = index;
UpdateNavigationVisuals(index, animate: true);
}
@@ -654,7 +658,7 @@ private void UpdateNavigationVisuals(int index, bool animate)
if (WorkflowPillTranslate == null)
return;
- var target = Math.Clamp(index, 0, 5) * 150d;
+ var target = Math.Clamp(index, 0, 6) * 128d;
if (animate)
{
var animation = new DoubleAnimation(target, TimeSpan.FromMilliseconds(190))
@@ -669,7 +673,7 @@ private void UpdateNavigationVisuals(int index, bool animate)
WorkflowPillTranslate.X = target;
}
- var buttons = new[] { NavExplorerButton, NavLiveButton, NavEventsButton, NavAlarmButton, NavGooseButton, NavDiagnosticsButton };
+ var buttons = new[] { NavExplorerButton, NavLiveButton, NavEventsButton, NavAlarmButton, NavGooseButton, NavDiagnosticsButton, NavFatButton };
for (var i = 0; i < buttons.Length; i++)
buttons[i].Foreground = i == index ? Brushes.White : new SolidColorBrush(Color.FromRgb(71, 84, 103));
}
@@ -2268,6 +2272,7 @@ private async Task ShutdownApplicationAsync()
{
_uiFlushTimer.Stop();
_progressAnimationTimer.Stop();
+ DisposeIntegratedFatWorkspace();
foreach (var device in Devices)
{
diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs
index e0130642f..a48a2a097 100644
--- a/Models/IoTesting/IoSignalDisplayName.cs
+++ b/Models/IoTesting/IoSignalDisplayName.cs
@@ -16,21 +16,29 @@ public static string Format(string? signalName, string? iecReference)
{
var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim();
var reference = iecReference?.Trim() ?? string.Empty;
-
var phaseMatch = PhaseToken().Match(reference);
if (phaseMatch.Success)
{
var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant();
- var suffix = $"Phs{phase}";
- if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase))
- return name;
+ if (phase.Length == 1 &&
+ name.Equals($"Thd{phase}", StringComparison.OrdinalIgnoreCase))
+ {
+ name = "Thd";
+ }
+
+ var suffix = $"Phs {phase}";
+ if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith($" Phs{phase}", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{name[..name.LastIndexOf(' ')]} {suffix}";
+ }
return $"{name} {suffix}";
}
- if (TryExtractLogicalNodeContext(reference, out var logicalNodeClass, out var dataObject)
- && RequiresLogicalNodeOwner(dataObject)
- && name.Equals(dataObject, StringComparison.OrdinalIgnoreCase))
+ if (TryExtractLogicalNodeContext(reference, out var logicalNodeClass, out var dataObject) &&
+ RequiresLogicalNodeOwner(dataObject) &&
+ name.Equals(dataObject, StringComparison.OrdinalIgnoreCase))
{
return $"{logicalNodeClass} {name}";
}
@@ -38,6 +46,20 @@ public static string Format(string? signalName, string? iecReference)
return name;
}
+ ///
+ /// Formats from the first candidate that still carries an IEC phase token. Runtime,
+ /// event-log and report identities are allowed to differ, but presentation must not
+ /// silently fall back to a DO-only name such as A/A/A when any canonical identity still
+ /// proves phsA/phsB/phsC or a phase-to-phase branch.
+ ///
+ public static string FormatFromCandidates(string? signalName, params string?[] references)
+ {
+ references ??= Array.Empty();
+ var phaseReference = references.FirstOrDefault(reference =>
+ !string.IsNullOrWhiteSpace(reference) && PhaseToken().IsMatch(reference));
+ return Format(signalName, phaseReference ?? references.FirstOrDefault());
+ }
+
private static bool TryExtractLogicalNodeContext(
string reference,
out string logicalNodeClass,
@@ -45,7 +67,6 @@ private static bool TryExtractLogicalNodeContext(
{
logicalNodeClass = string.Empty;
dataObject = string.Empty;
-
if (string.IsNullOrWhiteSpace(reference))
return false;
@@ -53,7 +74,6 @@ private static bool TryExtractLogicalNodeContext(
var tail = slashIndex >= 0 && slashIndex + 1 < reference.Length
? reference[(slashIndex + 1)..]
: reference;
-
var dotIndex = tail.IndexOf('.');
var dollarIndex = tail.IndexOf('$');
var separatorIndex = dotIndex switch
@@ -62,7 +82,6 @@ private static bool TryExtractLogicalNodeContext(
>= 0 => dotIndex,
_ => dollarIndex
};
-
if (separatorIndex <= 0 || separatorIndex + 1 >= tail.Length)
return false;
@@ -77,11 +96,9 @@ private static bool TryExtractLogicalNodeContext(
var tokens = remainder.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length == 0)
return false;
-
var dataObjectIndex = IsFunctionalConstraint(tokens[0]) ? 1 : 0;
if (dataObjectIndex >= tokens.Length)
return false;
-
dataObject = NormalizeDataObjectToken(tokens[dataObjectIndex]);
}
else
@@ -110,7 +127,6 @@ private static bool TryExtractLogicalNodeClass(string logicalNodeToken, out stri
var end = token.Length;
while (end > 0 && char.IsDigit(token[end - 1]))
end--;
-
if (end == 0)
return false;
diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs
index cc6afbbfd..aea888119 100644
--- a/Models/IoTesting/IoTestModels.cs
+++ b/Models/IoTesting/IoTestModels.cs
@@ -493,7 +493,23 @@ private set
public string LiveBindingReason { get => _liveBindingReason; private set => Set(ref _liveBindingReason, value ?? string.Empty); }
public string LiveDeviceId { get => _liveDeviceId; private set => Set(ref _liveDeviceId, value ?? string.Empty); }
- public string LiveSignalReference { get => _liveSignalReference; private set => Set(ref _liveSignalReference, value ?? string.Empty); }
+ public string LiveSignalReference
+ {
+ get => _liveSignalReference;
+ private set
+ {
+ if (Set(ref _liveSignalReference, value ?? string.Empty))
+ Raise(nameof(DisplaySignalName));
+ }
+ }
+ [JsonIgnore]
+ public string DisplaySignalName => IoSignalDisplayName.FormatFromCandidates(
+ SignalName,
+ LiveSignalReference,
+ EventLogSearchReference,
+ SourceIecReference,
+ ReportDisplayReference,
+ ObjectReference);
public bool IsLiveBound => LiveBindingState is IoTestLiveBindingState.BoundExact or IoTestLiveBindingState.BoundNormalized or IoTestLiveBindingState.LivePointReady;
public string LiveBindingText => LiveBindingState switch
{
diff --git a/SasOperationalUiPolicy.cs b/SasOperationalUiPolicy.cs
index cd9cd10d7..a095e8fa0 100644
--- a/SasOperationalUiPolicy.cs
+++ b/SasOperationalUiPolicy.cs
@@ -24,7 +24,7 @@ internal static class SasOperationalUiPolicy
private static readonly ConditionalWeakTable