Skip to content
10 changes: 8 additions & 2 deletions MainWindow.IecNaturalSort.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ public partial class MainWindow
private readonly HashSet<DataGrid> _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)
{
Expand All @@ -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)
{
Expand Down
80 changes: 80 additions & 0 deletions MainWindow.LivePointViewportPriority.cs
Original file line number Diff line number Diff line change
@@ -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<DataGrid> _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<string, Iec61850MonitorDevice> _uiPointOwners =
new(StringComparer.OrdinalIgnoreCase);
private bool _uiPointOwnersDirty = true;

private IReadOnlyDictionary<string, Iec61850MonitorDevice> 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))

Check warning on line 74 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 74 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 74 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.
return true;
if (_priorityPointUiKeys.TryDequeue(out key))

Check warning on line 76 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 76 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 76 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.
return true;
return _pendingPointUiKeys.TryDequeue(out key);

Check warning on line 78 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 78 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 78 in MainWindow.LivePointViewportPriority.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.
}
}
43 changes: 26 additions & 17 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -149,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(
Expand All @@ -160,6 +158,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();
Expand Down Expand Up @@ -2575,6 +2574,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;
}
}
Expand Down Expand Up @@ -2624,21 +2627,27 @@ 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++;
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.
Expand Down
43 changes: 43 additions & 0 deletions Models/LivePointViewportPriorityTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Collections.Concurrent;

namespace ArIED61850Tester.Models;

/// <summary>
/// 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.
/// </summary>
public enum LivePointViewportKind { Explorer, Global }

public sealed class LivePointViewportPriorityTracker
{
private readonly ConcurrentDictionary<string, byte> _explorer =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte> _global =
new(StringComparer.Ordinal);

private ConcurrentDictionary<string, byte> For(LivePointViewportKind viewport)
=> viewport == LivePointViewportKind.Global ? _global : _explorer;

/// <returns>True only when this exact point first became realized.</returns>
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;
}
6 changes: 5 additions & 1 deletion tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions tests/ARSAS.Tests/RealizedLiveViewportPriorityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// Real data grids own only viewport hints. No acquisition snapshot or event is
/// discarded: every point retains its normal FIFO key independent of priority.
/// </summary>
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);
}
}
Loading