From 35d3d5c5504f40f99b91ef384a1abd1173c63ea1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:38:04 +0700 Subject: [PATCH 1/7] perf(ssot): identity-only concurrent realized Explorer and Global viewport hints --- Models/LivePointViewportPriorityTracker.cs | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Models/LivePointViewportPriorityTracker.cs diff --git a/Models/LivePointViewportPriorityTracker.cs b/Models/LivePointViewportPriorityTracker.cs new file mode 100644 index 000000000..1d72fc340 --- /dev/null +++ b/Models/LivePointViewportPriorityTracker.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace ArIED61850Tester.Models; + +/// +/// Immutable identity-only viewport hints for the *existing* canonical point +/// presentation queue. No process value, session, event or IEC observation is +/// owned here. Independent Explorer and Global grids may realize the same row. +/// Concurrent reads are safe from the acquisition callback thread. +/// +public enum LivePointViewportKind { Explorer, Global } + +public sealed class LivePointViewportPriorityTracker +{ + private readonly ConcurrentDictionary _explorer = + new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _global = + new(StringComparer.Ordinal); + + private ConcurrentDictionary For(LivePointViewportKind viewport) + => viewport == LivePointViewportKind.Global ? _global : _explorer; + + /// True only when this exact point first became realized. + public bool Realize(LivePointViewportKind viewport, string exactKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(exactKey); + return For(viewport).TryAdd(exactKey, 0); + } + + public void Unrealize(LivePointViewportKind viewport, string exactKey) + { + if (!string.IsNullOrWhiteSpace(exactKey)) + For(viewport).TryRemove(exactKey, out _); + } + + public void Clear(LivePointViewportKind viewport) => For(viewport).Clear(); + + public bool IsRealized(string exactKey) + => !string.IsNullOrWhiteSpace(exactKey) && + (_explorer.ContainsKey(exactKey) || _global.ContainsKey(exactKey)); + + public int RealizedCount => _explorer.Count + _global.Count; +} From 56bd6e17cf81a893715365f33edc11cc22d00ca1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:38:31 +0700 Subject: [PATCH 2/7] perf(ui): prioritize actually realized virtual DataGrid rows with fair cross-IED FIFO and cached owner lookup --- MainWindow.LivePointViewportPriority.cs | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 MainWindow.LivePointViewportPriority.cs diff --git a/MainWindow.LivePointViewportPriority.cs b/MainWindow.LivePointViewportPriority.cs new file mode 100644 index 000000000..e9c9f6751 --- /dev/null +++ b/MainWindow.LivePointViewportPriority.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly LivePointViewportPriorityTracker _livePointViewport = new(); + private readonly HashSet _viewportPriorityAttachedGrids = new(); + + // Dictionary is owned by the WPF dispatcher. Changes to the ObservableCollection + // invalidate it: never scan/group every connected IED on every 200-ms flush. + private readonly Dictionary _uiPointOwners = + new(StringComparer.OrdinalIgnoreCase); + private bool _uiPointOwnersDirty = true; + + private IReadOnlyDictionary UiPointOwners() + { + if (!_uiPointOwnersDirty) return _uiPointOwners; + _uiPointOwners.Clear(); + foreach (var device in Devices) + _uiPointOwners.TryAdd(device.DeviceId, device); + _uiPointOwnersDirty = false; + return _uiPointOwners; + } + + private void AttachLivePointViewportPriority(DataGrid? grid) + { + if (grid is null || !_viewportPriorityAttachedGrids.Add(grid)) return; + grid.LoadingRow += LivePointViewport_LoadingRow; + grid.UnloadingRow += LivePointViewport_UnloadingRow; + grid.Unloaded += LivePointViewport_Unloaded; + } + + private LivePointViewportKind LivePointViewportType(DataGrid grid) + => ReferenceEquals(grid, GlobalLiveGrid) + ? LivePointViewportKind.Global : LivePointViewportKind.Explorer; + + private void LivePointViewport_LoadingRow(object? sender, DataGridRowEventArgs e) + { + if (sender is not DataGrid grid || + e.Row.Item is not Iec61850MonitorPoint point) return; + var key = CanonicalPointSnapshotAuthority.UiQueueKey(point); + if (!_livePointViewport.Realize(LivePointViewportType(grid), key)) return; + + // When a user scrolls into a long-existing backlog, prioritize only + // this newly realized row; do not re-enumerate 25k pending IED values. + if (_pendingPointSnapshots.ContainsKey(key)) + _priorityPointUiKeys.Enqueue(key); + } + + private void LivePointViewport_UnloadingRow(object? sender, DataGridRowEventArgs e) + { + if (sender is not DataGrid grid || + e.Row.Item is not Iec61850MonitorPoint point) return; + _livePointViewport.Unrealize(LivePointViewportType(grid), + CanonicalPointSnapshotAuthority.UiQueueKey(point)); + } + + private void LivePointViewport_Unloaded(object sender, RoutedEventArgs e) + { + if (sender is DataGrid grid && ReferenceEquals(e.OriginalSource, grid)) + _livePointViewport.Clear(LivePointViewportType(grid)); + } + + // Every fourth *accepted* batch slot is reserved for the older background + // FIFO. This prevents a continuous visible Report storm from starving + // other IEDs, while newly visible cells take the first three slots. + private bool TryDequeueUiPointKey(int processedPoints, out string key) + { + if ((processedPoints & 3) == 3 && + _pendingPointUiKeys.TryDequeue(out key)) + return true; + if (_priorityPointUiKeys.TryDequeue(out key)) + return true; + return _pendingPointUiKeys.TryDequeue(out key); + } +} From fff51fa41e66c003c57fbb169fe4dfc35f67d314 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:39:00 +0700 Subject: [PATCH 3/7] perf(ssot): route canonical point snapshots through realized viewport priority; preserve FIFO and association fencing --- MainWindow.xaml.cs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index b3c252f37..431f4c541 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -95,15 +95,9 @@ public Iec61850MonitorDevice? SelectedDevice ClearPendingControlConfirmations(_selectedDevice); } _selectedDevice = value; - // Make the currently selected IED's already queued visible values - // eligible on the next frame instead of waiting behind other IEDs. - if (_selectedDevice is { } priorityDevice) - foreach (var visible in priorityDevice.Points.Take(48)) - { - var key = CanonicalPointSnapshotAuthority.UiQueueKey(visible); - if (_pendingPointSnapshots.ContainsKey(key)) - _priorityPointUiKeys.Enqueue(key); - } + // WPF LoadingRow now prioritizes actual virtualized viewport rows + // on this device switch. Never assume the first 48 data-model rows + // are currently visible after scroll/filter/sort. if (_selectedDevice != null) { ClearPendingControlConfirmations(_selectedDevice); @@ -160,6 +154,7 @@ public MainWindow() if (version.Length > 0) Title = $"ARSAS v{version} - Smart IEC 61850 Communication Tester"; DataContext = this; + Devices.CollectionChanged += (_, _) => _uiPointOwnersDirty = true; _controlShoutTimer.Tick += (_, _) => { _controlShoutTimer.Stop(); @@ -2575,6 +2570,10 @@ private void Runtime_PointUpdated(Iec61850PointSnapshot snapshot) new CanonicalPointSnapshotBatch(snapshot, snapshot.IsValueEdge))) { _pendingPointUiKeys.Enqueue(key); + // Queue urgent work only for rows WPF has actually realized. + // Exact FC-qualified identity; no second value store or SOE. + if (_livePointViewport.IsRealized(key)) + _priorityPointUiKeys.Enqueue(key); return; } } @@ -2624,14 +2623,10 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e) var uiStartTicks = Stopwatch.GetTimestamp(); var uiDeadlineTicks = uiStartTicks + Stopwatch.Frequency / 125; // ~8 ms var processedPoints = 0; - var deviceOwners = Devices - .GroupBy(device => device.DeviceId, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), - StringComparer.OrdinalIgnoreCase); + var deviceOwners = UiPointOwners(); while (processedPoints < UiPointBatchLimit && Stopwatch.GetTimestamp() < uiDeadlineTicks && - (_priorityPointUiKeys.TryDequeue(out var pointKey) || - _pendingPointUiKeys.TryDequeue(out pointKey))) + TryDequeueUiPointKey(processedPoints, out var pointKey)) { if (!_pendingPointSnapshots.TryRemove(pointKey, out var pending)) continue; processedPoints++; From 77b7f69770735e9fdef8f74d2bd864a6af4fc29c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:39:18 +0700 Subject: [PATCH 4/7] perf(ui): attach virtualization LoadingRow priority to Explorer and Global Live DataGrids --- MainWindow.IecNaturalSort.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs index 5bc571357..0d9f34089 100644 --- a/MainWindow.IecNaturalSort.cs +++ b/MainWindow.IecNaturalSort.cs @@ -18,7 +18,10 @@ public partial class MainWindow private readonly HashSet _iecNaturallySortedGrids = new(); private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) - => AttachIecNaturalGridSort(sender as DataGrid, IecNaturalLiveMonitorSort.Instance); + { + AttachLivePointViewportPriority(sender as DataGrid); + AttachIecNaturalGridSort(sender as DataGrid, IecNaturalLiveMonitorSort.Instance); + } private void CommandGrid_Loaded(object sender, RoutedEventArgs e) { @@ -29,7 +32,10 @@ private void CommandGrid_Loaded(object sender, RoutedEventArgs e) } private void GlobalLiveGrid_Loaded(object sender, RoutedEventArgs e) - => AttachIecNaturalGridSort(sender as DataGrid, IecNaturalGlobalMonitorSort.Instance); + { + AttachLivePointViewportPriority(sender as DataGrid); + AttachIecNaturalGridSort(sender as DataGrid, IecNaturalGlobalMonitorSort.Instance); + } private void AttachIecNaturalGridSort(DataGrid? grid, IComparer comparer) { From ae9b1f4c0088b649c5bf5bd34988279aa4ed695b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:39:55 +0700 Subject: [PATCH 5/7] test(perf): guard fair realized viewport priority instead of hardcoded first 48 rows --- tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs index 64eb46f11..6ae93981d 100644 --- a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs @@ -22,7 +22,11 @@ public void SnapshotReportCoalescing_IsLosslessForEdgesAndUsesBoundedUiSlices() .Split("private void Signal_PropertyChanged(")[0]; Assert.Contains("processedPoints < UiPointBatchLimit", flush); Assert.Contains("Stopwatch.GetTimestamp() < uiDeadlineTicks", flush); - Assert.Contains("_priorityPointUiKeys.TryDequeue(out var pointKey)", flush); + Assert.Contains("TryDequeueUiPointKey(processedPoints, out var pointKey)", flush); + var viewport = Read("MainWindow.LivePointViewportPriority.cs"); + Assert.Contains("_priorityPointUiKeys.TryDequeue(out key)", viewport); + Assert.Contains("_pendingPointUiKeys.TryDequeue(out key)", viewport); + Assert.Contains("(processedPoints & 3) == 3", viewport); Assert.Contains("_pendingPointSnapshots.TryRemove(pointKey, out var pending)", flush); Assert.DoesNotContain("_pendingPointSnapshots.Keys.ToArray()", flush); Assert.Contains("CanonicalPointSnapshotAuthority.Project(", flush); From de52da2cc36e7cc320ac96e25235b024677bfc0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:40:33 +0700 Subject: [PATCH 6/7] test(ssot): verify 32k-row scrolling hints, concurrency, shared grid identity and lossless canonical FIFO --- .../RealizedLiveViewportPriorityTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/ARSAS.Tests/RealizedLiveViewportPriorityTests.cs diff --git a/tests/ARSAS.Tests/RealizedLiveViewportPriorityTests.cs b/tests/ARSAS.Tests/RealizedLiveViewportPriorityTests.cs new file mode 100644 index 000000000..ce8020a8b --- /dev/null +++ b/tests/ARSAS.Tests/RealizedLiveViewportPriorityTests.cs @@ -0,0 +1,121 @@ +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Real data grids own only viewport hints. No acquisition snapshot or event is +/// discarded: every point retains its normal FIFO key independent of priority. +/// +public sealed class RealizedLiveViewportPriorityTests +{ + [Fact] + public void ConcurrentTwoGridHints_PreserveSameExactReferenceWithoutCrossGridRemoval() + { + var viewport = new LivePointViewportPriorityTracker(); + const string key = "SG7|LD/MMXU1.A.phsA.cVal.mag.f|MX"; + const string distinctFc = "SG7|LD/MMXU1.A.phsA.cVal.mag.f|ST"; + + Assert.False(viewport.IsRealized(key)); + Assert.True(viewport.Realize(LivePointViewportKind.Explorer, key)); + Assert.False(viewport.Realize(LivePointViewportKind.Explorer, key)); + Assert.True(viewport.Realize(LivePointViewportKind.Global, key)); + Assert.Equal(2, viewport.RealizedCount); + viewport.Unrealize(LivePointViewportKind.Explorer, key); + Assert.True(viewport.IsRealized(key)); + Assert.False(viewport.IsRealized(distinctFc)); + viewport.Clear(LivePointViewportKind.Global); + Assert.False(viewport.IsRealized(key)); + Assert.Equal(0, viewport.RealizedCount); + } + + [Fact] + public void ScrollRecycling_OnlyActualRowsRemainPriorityHints() + { + var viewport = new LivePointViewportPriorityTracker(); + const int modelSize = 32_900; + var keys = Enumerable.Range(0, modelSize) + .Select(n => $"SG7|LD/MMXU1.Do{n}.stVal|ST").ToArray(); + foreach (var index in Enumerable.Range(10200, 55)) + Assert.True(viewport.Realize(LivePointViewportKind.Explorer, keys[index])); + Assert.Equal(55, viewport.RealizedCount); + Assert.True(viewport.IsRealized(keys[10223])); + Assert.False(viewport.IsRealized(keys[0])); + + foreach (var index in Enumerable.Range(10200, 55)) + viewport.Unrealize(LivePointViewportKind.Explorer, keys[index]); + foreach (var index in Enumerable.Range(24000, 42)) + viewport.Realize(LivePointViewportKind.Explorer, keys[index]); + Assert.Equal(42, viewport.RealizedCount); + Assert.False(viewport.IsRealized(keys[10223])); + Assert.True(viewport.IsRealized(keys[24001])); + + viewport.Clear(LivePointViewportKind.Explorer); + Assert.Equal(0, viewport.RealizedCount); + } + + [Fact] + public void BackgroundAcquisitionThread_CanSafelyInspectVisibleSnapshotHints() + { + var viewport = new LivePointViewportPriorityTracker(); + const string key = "SG7|LD/XCBR1.Pos.stVal|ST"; + var tasks = Enumerable.Range(0, 4).Select(worker => Task.Run(() => + { + for (var iteration = 0; iteration < 2000; iteration++) + { + if (worker == 0) + { + viewport.Realize(LivePointViewportKind.Explorer, key); + viewport.Unrealize(LivePointViewportKind.Explorer, key); + } + else + { + _ = viewport.IsRealized(key); + } + } + })); + Task.WaitAll(tasks.ToArray()); + Assert.False(viewport.IsRealized(key)); + } + + [Fact] + public void WpfContract_UsesActuallyRealizedRowsAndStillProjectsEveryQueuedPoint() + { + var partial = Read("MainWindow.LivePointViewportPriority.cs"); + var loaded = Read("MainWindow.IecNaturalSort.cs"); + var main = Read("MainWindow.xaml.cs"); + var flush = main.Split("private void UiFlushTimer_Tick(")[1] + .Split("private void Signal_PropertyChanged(")[0]; + var ingress = main.Split("private void Runtime_PointUpdated(")[1] + .Split("private void Runtime_EventRaised(")[0]; + + Assert.Contains("grid.LoadingRow += LivePointViewport_LoadingRow", partial); + Assert.Contains("grid.UnloadingRow += LivePointViewport_UnloadingRow", partial); + Assert.Contains("_livePointViewport.Realize(", partial); + Assert.Contains("_livePointViewport.Unrealize(", partial); + Assert.Contains("_livePointViewport.Clear(", partial); + Assert.Contains("AttachLivePointViewportPriority(sender as DataGrid)", loaded); + Assert.Contains("Devices.CollectionChanged += ", main); + Assert.Contains("UiPointOwners()", flush); + Assert.DoesNotContain("Devices.GroupBy(", flush); + Assert.Contains("_pendingPointUiKeys.Enqueue(key)", ingress); + Assert.Contains("_livePointViewport.IsRealized(key)", ingress); + Assert.Contains("_priorityPointUiKeys.Enqueue(key)", ingress); + Assert.Contains("_pendingPointSnapshots.TryRemove(pointKey, out var pending)", flush); + Assert.Contains("CanonicalPointSnapshotAuthority.Project(", flush); + Assert.Contains("snapshot.ObservationAssociation != observedOwner.LiveValues.Association", flush); + Assert.Contains("Events.AddRange(eventBatch)", flush); + Assert.DoesNotContain("priorityDevice.Points.Take(48)", main); + Assert.DoesNotContain("_pendingPointSnapshots.Clear()", flush); + } + + private static string Read(string name) + { + for (DirectoryInfo? folder = new(AppContext.BaseDirectory); + folder is not null; folder = folder.Parent) + { + var path = Path.Combine(folder.FullName, name); + if (File.Exists(path)) return File.ReadAllText(path); + } + throw new FileNotFoundException(name); + } +} From a3f3960959c381b5b589b7576e6946a51db9f136 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 15:42:06 +0700 Subject: [PATCH 7/7] fix(perf): attach initial viewport before layout and rebuild owner index safely after in-place DeviceId rebind --- MainWindow.xaml.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 431f4c541..07826e7e0 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -143,6 +143,10 @@ public Iec61850MonitorDevice? SelectedDevice public MainWindow() { InitializeComponent(); + // Register before first WPF layout; initial realized row events can + // precede Loaded, while the existing Loaded hooks stay idempotent. + AttachLivePointViewportPriority(ExplorerLiveValuesGrid); + AttachLivePointViewportPriority(GlobalLiveGrid); // The actual deployed assembly version, never an invented UI number. var assembly = typeof(MainWindow).Assembly; var compiled = assembly.GetCustomAttributes( @@ -2632,8 +2636,18 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e) processedPoints++; var snapshot = pending.Snapshot; var point = snapshot.Point; - if (!deviceOwners.TryGetValue(point.DeviceId, out var observedOwner)) - continue; + if (!deviceOwners.TryGetValue(point.DeviceId, out var observedOwner) || + !string.Equals(observedOwner.DeviceId, point.DeviceId, + StringComparison.OrdinalIgnoreCase)) + { + // DeviceId is project-deserializable. If it was rebound + // in-place without an ObservableCollection mutation, rebuild + // once instead of dropping valid session observations. + _uiPointOwnersDirty = true; + deviceOwners = UiPointOwners(); + if (!deviceOwners.TryGetValue(point.DeviceId, out observedOwner)) + continue; + } // Reject queued frames from an earlier MMS connection even if // the new session resets its point Sequence counter to zero. // Never re-activate Paused/Offline rows from late runtime callbacks.