From 7bc1be1ffc0b110a3eae3c2fe30ab9179879b406 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:00:47 +0700 Subject: [PATCH 01/63] ui(ied-browser): use ARSAS-specific IEC 61850 service navigation order --- Models/IedBrowserPresentation.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index aba5a1760..510671bff 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -380,16 +380,12 @@ IedBrowserTreeRow Category(string name, string key, Func> children) => new("category/" + key, name, "Category", 0, loadChildren: children); + // ARSAS navigation order is a presentation choice, not a wire/model contract. + // Stable category keys and lazy children preserve existing selected paths and + // avoid any additional MMS discovery, control or report requests. var categories = new List { - Category("GOOSE", "goose", () => BlockGroups( - IedBrowserGooseDirectory.Resolve(model, sclModel), "GoCB", labelIed)), Category("Reports", "reports", () => ReportGroups(rcbs, labelIed)), - Category("Setting Groups", "settings", () => BlockGroups(model?.SettingGroupControls, "SGCB", labelIed)), - // Files inventory belongs exclusively in the right-side - // virtualized grid. Nested file nodes duplicate content and - // inflate the compact engineering navigation. - new("category/files", "Files", "Category", 0), Category("DataSets", "datasets", () => DataSetGroups(model?.DataSets, labelIed)), Category("Data Model", "model", () => model?.LogicalDevices.Select(ld => @@ -405,7 +401,14 @@ IedBrowserTreeRow Category(string name, string key, new IedBrowserTreeRow("model/da/" + attr.MmsReference + "/" + ordinal, attr.AttributePath, "DA", 4, attr)).ToArray())) .ToArray())).ToArray())).ToArray() - ?? Array.Empty()) + ?? Array.Empty()), + // Files inventory belongs exclusively in the right-side + // virtualized grid. Nested file nodes duplicate content and + // inflate the compact engineering navigation. + new("category/files", "Files", "Category", 0), + Category("GOOSE", "goose", () => BlockGroups( + IedBrowserGooseDirectory.Resolve(model, sclModel), "GoCB", labelIed)), + Category("Setting Groups", "settings", () => BlockGroups(model?.SettingGroupControls, "SGCB", labelIed)) }; return categories; // ARSAS IED Explorer already owns device selection; never duplicate it. } From 8557114659cff9a1a5a2ccff1ed1cc6baf604db6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:00:51 +0700 Subject: [PATCH 02/63] test(ied-browser): assert six service category labels and immutable keys --- tests/ARSAS.Tests/IedBrowserPresentationTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs index 1e3aa10f1..2825580a0 100644 --- a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs +++ b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs @@ -141,8 +141,12 @@ public void CategoriesFollowEngineeringNavigationOrder_WithoutMmsCalls() var projection = new IedBrowserTreeProjection(); projection.Reset(roots); Assert.Equal( - ["GOOSE", "Reports", "Setting Groups", "Files", "DataSets", "Data Model"], + ["Reports", "DataSets", "Data Model", "Files", "GOOSE", "Setting Groups"], projection.Rows.Select(x => x.Title).ToArray()); + Assert.Equal( + ["category/reports", "category/datasets", "category/model", + "category/files", "category/goose", "category/settings"], + projection.Rows.Select(x => x.Key).ToArray()); Assert.All(projection.Rows, x => Assert.Equal(0, x.Depth)); } From 6246c07c5acd92ded4f86f21621b16ee7c4245bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:04:53 +0700 Subject: [PATCH 03/63] test(ied-browser): preserve navigation routes without live IED model --- tests/ARSAS.Tests/IedBrowserPresentationTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs index 2825580a0..e1e9a49bf 100644 --- a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs +++ b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs @@ -148,6 +148,12 @@ public void CategoriesFollowEngineeringNavigationOrder_WithoutMmsCalls() "category/files", "category/goose", "category/settings"], projection.Rows.Select(x => x.Key).ToArray()); Assert.All(projection.Rows, x => Assert.Equal(0, x.Depth)); + + // A disconnected or SCL-only session retains the same category routes; + // reordering must not depend on live MMS or create placeholder children. + var withoutLiveModel = IedBrowserPresentation.Roots(null, Array.Empty()); + Assert.Equal(projection.Rows.Select(x => x.Key).ToArray(), + withoutLiveModel.Select(x => x.Key).ToArray()); } [Fact] From 6ba69896778bb599a001baed3d6255e0a4a82293 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:11:28 +0700 Subject: [PATCH 04/63] test(ied-browser): update RCB navigation regression to new category presentation order --- tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs b/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs index e54867e56..2dd0f7494 100644 --- a/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs +++ b/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs @@ -123,8 +123,8 @@ public void VirtualTreeStartsWithExactlySixCollapsedCategories() var rows = IedBrowserPresentation.Roots(null, [Rcb()]); var tree = new IedBrowserTreeProjection(); tree.Reset(rows); - Assert.Equal(new[] { "GOOSE", "Reports", "Setting Groups", "Files", - "DataSets", "Data Model" }, tree.Rows.Select(x => x.Title)); + Assert.Equal(new[] { "Reports", "DataSets", "Data Model", "Files", + "GOOSE", "Setting Groups" }, tree.Rows.Select(x => x.Title)); Assert.All(tree.Rows, x => Assert.False(x.IsExpanded)); var reports = Assert.Single(tree.Rows.Where(x => x.Key == "category/reports")); From c14d719f1dae7b87b100d04cdd36fd1c64ea20ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:45:26 +0700 Subject: [PATCH 05/63] perf(browser): index immutable acceptance targets once per model --- Models/IedBrowserAcceptanceMetrics.cs | 79 +++++++++++++++++---------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/Models/IedBrowserAcceptanceMetrics.cs b/Models/IedBrowserAcceptanceMetrics.cs index 2877e3ca0..f8e989670 100644 --- a/Models/IedBrowserAcceptanceMetrics.cs +++ b/Models/IedBrowserAcceptanceMetrics.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using AR.Iec61850.Discovery; namespace ArIED61850Tester.Models; @@ -29,26 +30,56 @@ public sealed record IedBrowserAcceptanceMetrics( $"· q {QualityDecoded}/{QualityObserved} · t {TimestampDecoded}/{TimestampObserved} " + "· consumer mismatch not measured"; - public static IedBrowserAcceptanceMetrics Capture( - LiveIedModelDiscoveryDocument model, - IedBrowserLiveValues ledger, - IedBrowserSclEnumEvidence? enumEvidence = null, - IReadOnlyList? failures = null) + // Cache only immutable model declarations; never cache observed process values. + // A model's 25k+ attributes must not be regrouped on every WPF refresh. + private sealed record Target(string Reference, string Fc, bool Quality, bool Timestamp, bool SettingEnum); + private sealed record ModelTargets(Target[] Attributes, string[] CfPairOwners); + private static readonly ConditionalWeakTable + TargetCache = new(); + + private static ModelTargets IndexModel(LiveIedModelDiscoveryDocument model) { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(ledger); var readableFc = new HashSet( ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX"], StringComparer.OrdinalIgnoreCase); - var targets = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + var unique = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) .SelectMany(ln => ln.DataObjects).SelectMany(o => o.Attributes) .Where(a => readableFc.Contains(a.FunctionalConstraint) && !string.IsNullOrWhiteSpace(a.MmsReference)) .GroupBy(a => Key(a.MmsReference, a.FunctionalConstraint), StringComparer.Ordinal) - // Duplicate declarations are never counted as independently - // verified attributes, nor used to fabricate coverage. .Where(g => g.Count() == 1).Select(g => g.Single()).ToArray(); + var targets = unique.Select(attr => + { + var reference = IedBrowserLiveValues.CanonicalReference(attr.MmsReference); + var fc = attr.FunctionalConstraint; + return new Target(reference, fc, + reference.EndsWith(".q", StringComparison.Ordinal), + reference.EndsWith(".t", StringComparison.Ordinal), + fc.Equals("SG", StringComparison.OrdinalIgnoreCase) && + reference.EndsWith(".setVal", StringComparison.Ordinal) && + (attr.SclBType.Equals("Enum", StringComparison.OrdinalIgnoreCase) || + attr.MmsType.Equals("enumerated", StringComparison.OrdinalIgnoreCase) || + attr.MmsType.Equals("enum", StringComparison.OrdinalIgnoreCase))); + }).ToArray(); + var allCf = targets.Where(a => a.Fc.Equals("CF", StringComparison.OrdinalIgnoreCase)) + .Select(a => a.Reference).ToHashSet(StringComparer.Ordinal); + var owners = allCf.Where(x => + x.EndsWith(".units.SIUnit", StringComparison.Ordinal) && + allCf.Contains(x[..^".SIUnit".Length] + ".multiplier")) + .Select(x => x[..^".SIUnit".Length]).ToArray(); + return new ModelTargets(targets, owners); + } + + public static IedBrowserAcceptanceMetrics Capture( + LiveIedModelDiscoveryDocument model, + IedBrowserLiveValues ledger, + IedBrowserSclEnumEvidence? enumEvidence = null, + IReadOnlyList? failures = null) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(ledger); + var targets = TargetCache.GetValue(model, IndexModel); var fresh = 0; var qObserved = 0; var qDecoded = 0; @@ -58,12 +89,12 @@ public static IedBrowserAcceptanceMetrics Capture( var sgDecoded = 0; var missing = 0; - foreach (var target in targets) + foreach (var target in targets.Attributes) { - var reference = IedBrowserLiveValues.CanonicalReference(target.MmsReference); - var fc = target.FunctionalConstraint; - var isQuality = reference.EndsWith(".q", StringComparison.Ordinal); - var isTime = reference.EndsWith(".t", StringComparison.Ordinal); + var reference = target.Reference; + var fc = target.Fc; + var isQuality = target.Quality; + var isTime = target.Timestamp; var kind = isQuality ? "Quality" : isTime ? "Timestamp" : ""; if (!ledger.TryGet(reference, fc, out var observed, requiredEvidence: kind) || @@ -84,26 +115,14 @@ public static IedBrowserAcceptanceMetrics Capture( if (observed.TimeBits is { IsDecoded: true, HasWireProvenance: true }) tDecoded++; } - if (fc.Equals("SG", StringComparison.OrdinalIgnoreCase) && - reference.EndsWith(".setVal", StringComparison.Ordinal) && - (target.SclBType.Equals("Enum", StringComparison.OrdinalIgnoreCase) || - target.MmsType.Equals("enumerated", StringComparison.OrdinalIgnoreCase) || - target.MmsType.Equals("enum", StringComparison.OrdinalIgnoreCase))) + if (target.SettingEnum) { sgObserved++; if (enumEvidence?.TryFormat(reference, fc, observed.Value, out _, out _) == true) sgDecoded++; } } - var allCf = targets.Where(a => - a.FunctionalConstraint.Equals("CF", StringComparison.OrdinalIgnoreCase)) - .Select(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference)) - .ToHashSet(StringComparer.Ordinal); - var cfOwners = allCf.Where(x => - x.EndsWith(".units.SIUnit", StringComparison.Ordinal) && - allCf.Contains(x[..^".SIUnit".Length] + ".multiplier")) - .Select(x => x[..^".SIUnit".Length]) - .ToArray(); + var cfOwners = targets.CfPairOwners; var cfEvidence = new IedBrowserCfEvidencePresentation(ledger, model, null); var proved = cfOwners.Count(x => cfEvidence.TryFormatUnitsParent(x, "CF", out _, out _, out var authority) && @@ -111,7 +130,7 @@ public static IedBrowserAcceptanceMetrics Capture( var failedSamples = failures?.Count(x => x.Kind is IedBrowserReadAbsenceKind.Failed or IedBrowserReadAbsenceKind.Interrupted) ?? 0; - return new IedBrowserAcceptanceMetrics(targets.Length, fresh, + return new IedBrowserAcceptanceMetrics(targets.Attributes.Length, fresh, missing, failedSamples, cfOwners.Length, proved, sgObserved, sgDecoded, qObserved, qDecoded, tObserved, tDecoded); } From 5d09fc377a6c953c14455111b0dc3c834903461a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:48:49 +0700 Subject: [PATCH 06/63] perf(browser): async model schema and bounded read-only metrics publication --- MainWindow.IedBrowser.Performance.cs | 155 +++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 MainWindow.IedBrowser.Performance.cs diff --git a/MainWindow.IedBrowser.Performance.cs b/MainWindow.IedBrowser.Performance.cs new file mode 100644 index 000000000..11bd2f247 --- /dev/null +++ b/MainWindow.IedBrowser.Performance.cs @@ -0,0 +1,155 @@ +using System.Runtime.CompilerServices; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + // Weak model keys: indexed declarations are reused when returning to SG7_BCU, + // without retaining replaced engineering models indefinitely. + private readonly ConditionalWeakTable> + _iedBrowserSchemaTasks = new(); + private Task? _iedBrowserAwaitedSchema; + private Iec61850MonitorDevice? _iedBrowserAwaitedSchemaDevice; + private Iec61850MonitorDevice? _iedBrowserPreparingDevice; + private LiveIedModelDiscoveryDocument? _iedBrowserPreparingModel; + + private Task? _iedBrowserMetricsFlight; + private Iec61850MonitorDevice? _iedBrowserMetricsDevice; + private LiveIedModelDiscoveryDocument? _iedBrowserMetricsModel; + private long _iedBrowserMetricsAssociation; + private DateTime _iedBrowserMetricsStartedUtc; + + private void ShowIedBrowserPreparing(Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model) + { + if (ReferenceEquals(_iedBrowserPreparingDevice, device) && + ReferenceEquals(_iedBrowserPreparingModel, model)) + return; + _iedBrowserPreparingDevice = device; + _iedBrowserPreparingModel = model; + // Never leave the previous IED's tree clickable during model preparation. + _iedBrowserNavigationRenderTicket++; + _iedBrowserRenderedPage = null; + _iedBrowserNavigation.SelectFromTree(null); + _iedBrowserSyncingSelection = true; + try + { + IedBrowserTreeGrid.SelectedItem = null; + _iedBrowserTree.Reset([]); + IedBrowserTreeGrid.ItemsSource = _iedBrowserTree.Rows; + ReplaceIedBrowserDetails([]); + } + finally { _iedBrowserSyncingSelection = false; } + IedBrowserObjectPath.Text = device.Name + " · preparing engineering model"; + IedBrowserDetailStatus.Text = "Indexing model on background worker…"; + } + + private bool TryActivateIedBrowserSchema( + Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument? model) + { + if (ReferenceEquals(_iedBrowserValueSchemaIndex?.SourceModel, model)) + return true; + if (model is null) + { + _iedBrowserValueSchemaIndex = null; + return true; + } + var task = _iedBrowserSchemaTasks.GetValue(model, source => + Task.Run(() => new IedBrowserSchemaIndex(source))); + if (task.IsCompletedSuccessfully) + { + _iedBrowserValueSchemaIndex = task.Result; + _iedBrowserPreparingDevice = null; + _iedBrowserPreparingModel = null; + return true; + } + ShowIedBrowserPreparing(device, model); + if (task.IsFaulted || task.IsCanceled) + { + IedBrowserDetailStatus.Text = "Model indexing unavailable · " + + (task.Exception?.GetBaseException().Message ?? "worker canceled"); + return false; + } + if (!ReferenceEquals(_iedBrowserAwaitedSchema, task) || + !ReferenceEquals(_iedBrowserAwaitedSchemaDevice, device)) + { + _iedBrowserAwaitedSchema = task; + _iedBrowserAwaitedSchemaDevice = device; + _ = CompleteIedBrowserSchemaAsync(task, device, model, + device.LiveValues.Association); + } + return false; + } + + private async Task CompleteIedBrowserSchemaAsync( + Task task, Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, long association) + { + try { await task; } + catch (Exception) { /* The faulted state is shown by the guarded caller. */ } + if (_applicationCancellation.IsCancellationRequested || + MainTabs.SelectedIndex != IedBrowserWorkspaceIndex || + !ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel, model) || + device.LiveValues.Association != association) + return; + RefreshIedBrowser(); + } + + // SSOT coverage is not a five-Hz UI task. Model declarations are indexed + // separately; the observation ledger itself is never copied or modified. + private void QueueIedBrowserAcceptanceMetrics( + Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument model) + { + var association = device.LiveValues.Association; + var sameScope = ReferenceEquals(_iedBrowserMetricsDevice, device) && + ReferenceEquals(_iedBrowserMetricsModel, model) && + _iedBrowserMetricsAssociation == association; + if (!sameScope) + { + _iedBrowserMetricsDevice = device; + _iedBrowserMetricsModel = model; + _iedBrowserMetricsAssociation = association; + IedBrowserSubtitle.ToolTip = "IEC SSOT · calculating read-only coverage…"; + } + if (_iedBrowserMetricsFlight is { IsCompleted: false } || + (sameScope && DateTime.UtcNow - _iedBrowserMetricsStartedUtc < + TimeSpan.FromSeconds(5))) + return; + var failures = _iedBrowserReadFailureEvidence?.SnapshotCurrent(device, 32); + var enumEvidence = _iedBrowserSclEnumEvidence; + _iedBrowserMetricsStartedUtc = DateTime.UtcNow; + var flight = Task.Run(() => + IedBrowserAcceptanceMetrics.Capture(model, device.LiveValues, + enumEvidence, failures)); + _iedBrowserMetricsFlight = flight; + _ = PublishIedBrowserAcceptanceMetricsAsync( + flight, device, model, association); + } + + private async Task PublishIedBrowserAcceptanceMetricsAsync( + Task flight, + Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument model, long association) + { + IedBrowserAcceptanceMetrics metrics; + try { metrics = await flight; } + catch (Exception ex) + { + if (ReferenceEquals(SelectedDevice, device) && + ReferenceEquals(_iedBrowserMetricsFlight, flight)) + IedBrowserSubtitle.ToolTip = "IEC SSOT coverage unavailable · " + ex.Message; + return; + } + if (_applicationCancellation.IsCancellationRequested || + MainTabs.SelectedIndex != IedBrowserWorkspaceIndex || + !ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(_iedBrowserMetricsFlight, flight) || + !ReferenceEquals(device.LiveDiscoveryModel, model) || + device.LiveValues.Association != association) + return; + IedBrowserSubtitle.ToolTip = metrics.Summary + + " · sampled " + DateTime.Now.ToString("HH:mm:ss"); + } +} From 013a2d8265ee52ea175ed13d2a33d907f692b959 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:49:07 +0700 Subject: [PATCH 07/63] perf(browser): replace synchronous 25k-attribute UI operations with scheduled work --- MainWindow.IedBrowser.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 08a7aac63..0839d09ca 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1027,12 +1027,7 @@ private void RefreshIedBrowserVisibleValues() // adding another subscription. They do not claim consumer comparison // until distinct consumer projections have actually been compared. if (ReferenceEquals(model, device.LiveDiscoveryModel) && model is not null) - { - var failures = _iedBrowserReadFailureEvidence?.SnapshotCurrent(device, 32); - IedBrowserSubtitle.ToolTip = IedBrowserAcceptanceMetrics.Capture( - model, device.LiveValues, _iedBrowserSclEnumEvidence, - failures).Summary; - } + QueueIedBrowserAcceptanceMetrics(device, model); else IedBrowserSubtitle.ToolTip = "Offline/SCL declarations are not proof of fresh IEC MMS observations."; @@ -1974,11 +1969,9 @@ private void RefreshIedBrowser() catch (IOException) { /* An unavailable source file cannot poison in-memory browsing. */ } catch (UnauthorizedAccessException) { /* Still show the cached/discovered model. */ } - // Model index is immutable and independent from transient report/RCB - // refreshes. Build once per actual engineering schema object. - if (!ReferenceEquals(_iedBrowserValueSchemaIndex?.SourceModel, model)) - _iedBrowserValueSchemaIndex = model is null - ? null : new IedBrowserSchemaIndex(model); + // Heavy schema indexing must never block the WPF selection dispatcher. + if (!TryActivateIedBrowserSchema(device, model)) + return; var changed = _iedBrowserForceRefresh || !ReferenceEquals(device, _iedBrowserCachedDevice) || From c9ad636ba23cc0879bfbdb2f2ffdb5d69342a192 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:50:22 +0700 Subject: [PATCH 08/63] perf(browser): opt-in EventPipe timings without background logging or IO --- Services/IedBrowserPerformanceProbe.cs | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Services/IedBrowserPerformanceProbe.cs diff --git a/Services/IedBrowserPerformanceProbe.cs b/Services/IedBrowserPerformanceProbe.cs new file mode 100644 index 000000000..f1285c847 --- /dev/null +++ b/Services/IedBrowserPerformanceProbe.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using System.Diagnostics.Tracing; + +namespace ArIED61850Tester.Services; + +/// +/// Opt-in EventPipe/ETW diagnostics for SG7-size WPF model navigation. +/// Disabled by default: no file writes, new UI controls, or network operations. +/// Capture provider ARSAS-IEDBrowser-Performance with PerfView/dotnet-trace. +/// +[EventSource(Name = "ARSAS-IEDBrowser-Performance")] +internal sealed class IedBrowserPerformanceEvents : EventSource +{ + internal static readonly IedBrowserPerformanceEvents Log = new(); + + [Event(1, Level = EventLevel.Informational)] + public void TimedPhase(string phase, string device, double elapsedMs, int itemCount) + { + if (IsEnabled()) + WriteEvent(1, phase, device, elapsedMs, itemCount); + } +} + +/// +/// Measures an existing operation without scheduling, mutation or instrumentation +/// overhead when no external diagnostics listener is attached. +/// +internal readonly struct IedBrowserPerformanceProbe : IDisposable +{ + private readonly string? _phase; + private readonly string? _device; + private readonly int _items; + private readonly long _started; + + private IedBrowserPerformanceProbe(string phase, string device, int items) + { + _phase = phase; + _device = device; + _items = items; + _started = Stopwatch.GetTimestamp(); + } + + internal static IedBrowserPerformanceProbe Measure(string phase, string? device, int items = 0) + => IedBrowserPerformanceEvents.Log.IsEnabled() + ? new IedBrowserPerformanceProbe(phase, device ?? "", items) + : default; + + public void Dispose() + { + if (_started == 0) return; + IedBrowserPerformanceEvents.Log.TimedPhase( + _phase ?? "", _device ?? "", + Stopwatch.GetElapsedTime(_started).TotalMilliseconds, _items); + } +} From 9ac8bfd54c1b485755b8aaa5d311bd7169365f6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:50:44 +0700 Subject: [PATCH 09/63] perf(browser): instrument IED selection, UI flush and visible model work --- MainWindow.xaml.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ed7113b7c..7728e1838 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -77,6 +77,8 @@ public Iec61850MonitorDevice? SelectedDevice set { if (ReferenceEquals(_selectedDevice, value)) return; + using var selectionPerf = IedBrowserPerformanceProbe.Measure( + "IED.Select", value?.Name, value?.Signals.Count ?? 0); if (_selectedDevice != null) { _selectedDevice.IsActive = false; @@ -2577,6 +2579,8 @@ private static async Task WaitForDiscoveryProgressAnimationAsync( private void UiFlushTimer_Tick(object? sender, EventArgs e) { + using var flushPerf = IedBrowserPerformanceProbe.Measure( + "UI.Flush", SelectedDevice?.Name, _pendingPointSnapshots.Count); FlushGooseSubscriberUi(); var pointChanged = false; From d594bfae62e82b4fc5814f360ffd2d2030942623 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:50:48 +0700 Subject: [PATCH 10/63] perf(browser): instrument IED selection, UI flush and visible model work --- MainWindow.IedBrowser.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 0839d09ca..d92df4de9 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -978,6 +978,8 @@ private void RefreshIedBrowserVisibleValues() if (MainTabs.SelectedIndex != IedBrowserWorkspaceIndex || SelectedDevice is not { } device) return; + using var valuesPerf = IedBrowserPerformanceProbe.Measure( + "Browser.VisibleValues", device.Name, _iedBrowserDetails.Rows.Count); // RCB start/stop does not increment the process LiveValues ledger // version. Independently update just the already materialized RCB // row glyphs from runtime activation authority on the UI flush tick, @@ -1031,6 +1033,8 @@ private void RefreshIedBrowserVisibleValues() else IedBrowserSubtitle.ToolTip = "Offline/SCL declarations are not proof of fresh IEC MMS observations."; + using var unitsPerf = IedBrowserPerformanceProbe.Measure( + "Browser.Units", device.Name, device.Signals.Count); var engineeringUnits = cfEvidence.BuildVerifiedUnits(device.Signals, model); foreach (var row in _iedBrowserDetails.Rows) { @@ -1915,6 +1919,8 @@ private void RefreshIedBrowser() { if (IedBrowserTab == null || MainTabs.SelectedIndex != IedBrowserWorkspaceIndex) return; + using var browserPerf = IedBrowserPerformanceProbe.Measure( + "Browser.Refresh", SelectedDevice?.Name, SelectedDevice?.Signals.Count ?? 0); var device = SelectedDevice; ObserveIedBrowserDevice(device); From 06fa69f191c3f5c32161d05bea7cc9f9c02689ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:51:41 +0700 Subject: [PATCH 11/63] perf(browser): track configuration evidence revision independently of ST/MX traffic --- Models/IedBrowserLiveValues.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Models/IedBrowserLiveValues.cs b/Models/IedBrowserLiveValues.cs index ce05669e3..57e24b394 100644 --- a/Models/IedBrowserLiveValues.cs +++ b/Models/IedBrowserLiveValues.cs @@ -33,9 +33,13 @@ public sealed class IedBrowserLiveValues private readonly Dictionary _values = new(StringComparer.Ordinal); private long _version; + // Only CF changes invalidate engineering-unit projection; ordinary ST/MX + // report traffic must not rebuild its signal/unit directory. + private long _configurationVersion; private long _association; private bool _offline; public long Version { get { lock (_gate) return _version; } } + public long ConfigurationVersion { get { lock (_gate) return _configurationVersion; } } public long Association { get { lock (_gate) return _association; } } public bool IsOffline { get { lock (_gate) return _offline; } } @@ -63,6 +67,7 @@ public long BeginAssociation() _association++; _offline = false; _values.Clear(); + _configurationVersion++; _version++; return _association; } @@ -75,6 +80,7 @@ public void MarkOffline() { _offline = true; _association++; // invalidate in-flight callbacks even before reconnect + _configurationVersion++; _version++; } } @@ -86,6 +92,7 @@ public void Clear() _association++; _offline = true; _values.Clear(); + _configurationVersion++; _version++; } } @@ -231,6 +238,8 @@ quality is not ("Unknown" or "Pending / q not supplied" or _values[key] = isReport ? channels with { Report = observed } : channels with { Read = observed }; + if (string.Equals(fc, "CF", StringComparison.OrdinalIgnoreCase)) + _configurationVersion++; _version++; return true; } From cda46f3a0b31112b40cd0b371eb998470b5eb7b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:52:07 +0700 Subject: [PATCH 12/63] perf(browser): invalidate unit index on actual signal metadata change --- Models/SignalDefinition.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs index 7e5fe6e61..779829561 100644 --- a/Models/SignalDefinition.cs +++ b/Models/SignalDefinition.cs @@ -87,7 +87,23 @@ public string DisplayReference public string FunctionalConstraint { get; set; } = ""; public string DataType { get; set; } = ""; public string Category { get; set; } = ""; - public string Unit { get; set; } = ""; + // Monotonic generation for model metadata projection invalidation. This + // preserves the old plain Unit setter's UI notification behavior (none), + // without subscribing handlers to tens of thousands of SG7 signals. + private static long _unitMetadataRevision; + private string _unit = ""; + public static long UnitMetadataRevision => Interlocked.Read(ref _unitMetadataRevision); + public string Unit + { + get => _unit; + set + { + var next = value ?? ""; + if (string.Equals(_unit, next, StringComparison.Ordinal)) return; + _unit = next; + Interlocked.Increment(ref _unitMetadataRevision); + } + } public string Confidence { get; set; } = "Medium"; public string DataSetReference { get; set; } = ""; public string ReportControlReference { get; set; } = ""; From 6f036b2f1798c7a913fa8c6ef3ba400bf757741a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:52:58 +0700 Subject: [PATCH 13/63] perf(browser): cache verified engineering units by metadata and CF revision --- Models/IedBrowserUnitProjectionCache.cs | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Models/IedBrowserUnitProjectionCache.cs diff --git a/Models/IedBrowserUnitProjectionCache.cs b/Models/IedBrowserUnitProjectionCache.cs new file mode 100644 index 000000000..c319aed07 --- /dev/null +++ b/Models/IedBrowserUnitProjectionCache.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// UI-owned metadata projection cache. ST/MX Report updates never invalidate +/// units; exact CF publication, association, model, or SignalDefinition metadata +/// changes do. No copied observations, MMS side effects or cross-IED state. +/// +public sealed class IedBrowserUnitProjectionCache +{ + private readonly ConditionalWeakTable _devices = new(); + + public IReadOnlyDictionary Resolve(Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument? model, IedBrowserCfEvidencePresentation evidence) + => _devices.GetValue(device, static d => new Entry(d)).Resolve(device, model, evidence); + + private sealed class Entry + { + private long _collectionRevision; + private long _lastCollectionRevision = -1; + private long _lastUnitRevision = -1; + private long _lastConfigurationVersion = -1; + private long _lastAssociation = -1; + private LiveIedModelDiscoveryDocument? _model; + private LiveIedCanonicalModel? _canonical; + private IReadOnlyDictionary? _units; + + public Entry(Iec61850MonitorDevice device) + { + // One collection subscription per IED, not one handler per signal. + device.Signals.CollectionChanged += (_, _) => + Interlocked.Increment(ref _collectionRevision); + } + + public IReadOnlyDictionary Resolve(Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument? model, IedBrowserCfEvidencePresentation evidence) + { + var collection = Interlocked.Read(ref _collectionRevision); + var unit = SignalDefinition.UnitMetadataRevision; + var config = device.LiveValues.ConfigurationVersion; + var association = device.LiveValues.Association; + if (_units is not null && + ReferenceEquals(_model, model) && + ReferenceEquals(_canonical, device.LiveCanonicalModel) && + _lastCollectionRevision == collection && + _lastUnitRevision == unit && + _lastConfigurationVersion == config && + _lastAssociation == association) + return _units; + + var rebuilt = evidence.BuildVerifiedUnits(device.Signals, model); + _units = rebuilt; + _model = model; + _canonical = device.LiveCanonicalModel; + _lastCollectionRevision = collection; + _lastUnitRevision = unit; + _lastConfigurationVersion = config; + _lastAssociation = association; + return rebuilt; + } + } +} From 898c8d2ba14db4ef5bb4ddb0d2817a9204930b3c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:53:16 +0700 Subject: [PATCH 14/63] perf(browser): keep one unit projection cache per owner window --- MainWindow.IedBrowser.Performance.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.IedBrowser.Performance.cs b/MainWindow.IedBrowser.Performance.cs index 11bd2f247..c5e32ce75 100644 --- a/MainWindow.IedBrowser.Performance.cs +++ b/MainWindow.IedBrowser.Performance.cs @@ -6,6 +6,7 @@ namespace ArIED61850Tester; public partial class MainWindow { + private readonly IedBrowserUnitProjectionCache _iedBrowserUnitCache = new(); // Weak model keys: indexed declarations are reused when returning to SG7_BCU, // without retaining replaced engineering models indefinitely. private readonly ConditionalWeakTable> From 94dcc17b0e85c9cd0ac855d847f99baed32114a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:53:20 +0700 Subject: [PATCH 15/63] perf(browser): use invalidation-driven units instead of rebuilding on live ticks --- MainWindow.IedBrowser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d92df4de9..d7e7192e7 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1035,7 +1035,7 @@ private void RefreshIedBrowserVisibleValues() "Offline/SCL declarations are not proof of fresh IEC MMS observations."; using var unitsPerf = IedBrowserPerformanceProbe.Measure( "Browser.Units", device.Name, device.Signals.Count); - var engineeringUnits = cfEvidence.BuildVerifiedUnits(device.Signals, model); + var engineeringUnits = _iedBrowserUnitCache.Resolve(device, model, cfEvidence); foreach (var row in _iedBrowserDetails.Rows) { if (string.IsNullOrWhiteSpace(row.ValueReference)) continue; From e0b96e5d45d873d3ec49bedf789c10cec1074fa8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:54:23 +0700 Subject: [PATCH 16/63] perf(browser): move SCL inventory inspection and model preparation to guarded workers --- MainWindow.IedBrowser.Performance.cs | 83 +++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/MainWindow.IedBrowser.Performance.cs b/MainWindow.IedBrowser.Performance.cs index c5e32ce75..1eb768108 100644 --- a/MainWindow.IedBrowser.Performance.cs +++ b/MainWindow.IedBrowser.Performance.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using ArIED61850Tester.Services; using ArIED61850Tester.Models; namespace ArIED61850Tester; @@ -16,6 +18,11 @@ private readonly ConditionalWeakTable? _iedBrowserInventoryTask; + private Task? _iedBrowserAwaitedInventory; + private Iec61850MonitorDevice? _iedBrowserAwaitedInventoryDevice; + private Task? _iedBrowserMetricsFlight; private Iec61850MonitorDevice? _iedBrowserMetricsDevice; private LiveIedModelDiscoveryDocument? _iedBrowserMetricsModel; @@ -58,7 +65,12 @@ private bool TryActivateIedBrowserSchema( return true; } var task = _iedBrowserSchemaTasks.GetValue(model, source => - Task.Run(() => new IedBrowserSchemaIndex(source))); + Task.Run(() => + { + using var measure = IedBrowserPerformanceProbe.Measure( + "Schema.Index", device.Name); + return new IedBrowserSchemaIndex(source); + })); if (task.IsCompletedSuccessfully) { _iedBrowserValueSchemaIndex = task.Result; @@ -99,6 +111,67 @@ private async Task CompleteIedBrowserSchemaAsync( RefreshIedBrowser(); } + private bool TryResolveIedBrowserSclInventory(Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument? model, string sourcePath, DateTime sourceStamp, + out SclReportControlInventoryResult? inventory) + { + inventory = null; + if (sourceStamp == DateTime.MinValue) return true; + var iedName = EffectiveSclIedName(device); + var accessPoint = device.SclAccessPointName; + var key = string.Join("|", sourcePath, sourceStamp.Ticks, + iedName, accessPoint); + if (!string.Equals(_iedBrowserInventoryKey, key, StringComparison.Ordinal) || + _iedBrowserInventoryTask is null) + { + _iedBrowserInventoryKey = key; + _iedBrowserInventoryTask = Task.Run(() => + { + using var measure = IedBrowserPerformanceProbe.Measure( + "SCL.Inventory", device.Name); + return SclReportControlFilter.InspectFile(sourcePath, iedName, accessPoint); + }); + } + var task = _iedBrowserInventoryTask; + if (!task.IsCompleted) + { + if (model is not null) ShowIedBrowserPreparing(device, model); + else IedBrowserDetailStatus.Text = "Reading SCL inventory on background worker…"; + if (!ReferenceEquals(_iedBrowserAwaitedInventory, task) || + !ReferenceEquals(_iedBrowserAwaitedInventoryDevice, device)) + { + _iedBrowserAwaitedInventory = task; + _iedBrowserAwaitedInventoryDevice = device; + _ = CompleteIedBrowserInventoryAsync(task, device, model, + device.LiveValues.Association); + } + return false; + } + if (!task.IsCompletedSuccessfully) + { + IedBrowserDetailStatus.Text = "Source inventory unavailable · " + + (task.Exception?.GetBaseException().Message ?? "worker canceled"); + return true; // Same nonfatal behavior as the previous source-file inspector. + } + inventory = task.Result; + return true; + } + + private async Task CompleteIedBrowserInventoryAsync( + Task task, + Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument? model, long association) + { + try { await task; } + catch (Exception) { /* Nonfatal inventory failure is displayed on next refresh. */ } + if (_applicationCancellation.IsCancellationRequested || + MainTabs.SelectedIndex != IedBrowserWorkspaceIndex || + !ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel, model) || + device.LiveValues.Association != association) + return; + RefreshIedBrowser(); + } + // SSOT coverage is not a five-Hz UI task. Model declarations are indexed // separately; the observation ledger itself is never copied or modified. private void QueueIedBrowserAcceptanceMetrics( @@ -123,8 +196,12 @@ private void QueueIedBrowserAcceptanceMetrics( var enumEvidence = _iedBrowserSclEnumEvidence; _iedBrowserMetricsStartedUtc = DateTime.UtcNow; var flight = Task.Run(() => - IedBrowserAcceptanceMetrics.Capture(model, device.LiveValues, - enumEvidence, failures)); + { + using var measure = IedBrowserPerformanceProbe.Measure( + "Metrics.Capture", device.Name); + return IedBrowserAcceptanceMetrics.Capture(model, device.LiveValues, + enumEvidence, failures); + }); _iedBrowserMetricsFlight = flight; _ = PublishIedBrowserAcceptanceMetricsAsync( flight, device, model, association); From b215d24662fd85f5dc7880f154b087a1a9e7d33c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:54:41 +0700 Subject: [PATCH 17/63] perf(browser): remove synchronous SCL XML inspection from IED switching --- MainWindow.IedBrowser.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d7e7192e7..5ee77f493 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1999,20 +1999,10 @@ private void RefreshIedBrowser() ? _iedBrowserTree.ExpandedKeys() : new HashSet(StringComparer.Ordinal); - SclReportControlInventoryResult? inventory = null; - if (stamp != DateTime.MinValue) - { - try - { - inventory = SclReportControlFilter.InspectFile( - sourcePath, EffectiveSclIedName(device), device.SclAccessPointName); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException - or InvalidDataException or ArgumentException) - { - IedBrowserDetailStatus.Text = "Source inventory unavailable: " + ex.Message; - } - } + // Source inspection parses SCL XML; never perform it inside a click handler. + if (!TryResolveIedBrowserSclInventory( + device, model, sourcePath, stamp, out var inventory)) + return; var rcbs = BuildRcbExportRows(device, inventory, _iedBrowserSnapshot); if (rcbs.Count == 0 && model != null) From fbe205b9161a365edf4eacd70ed539c29bca6dd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:55:14 +0700 Subject: [PATCH 18/63] fix(wpf): constrain Command Dock to real shell viewport and preserve internal scroll --- MainWindow.WorkbenchP1PanelGeometry.cs | 40 +++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/MainWindow.WorkbenchP1PanelGeometry.cs b/MainWindow.WorkbenchP1PanelGeometry.cs index 041472ea3..52fa814d3 100644 --- a/MainWindow.WorkbenchP1PanelGeometry.cs +++ b/MainWindow.WorkbenchP1PanelGeometry.cs @@ -29,6 +29,7 @@ private sealed class P1PanelGeometryState public required GridSplitter DockSplitter { get; init; } public required Border DockGuide { get; init; } public bool DockDragging { get; set; } + public bool ApplyingGeometry { get; set; } public Dictionary DockHeightByWorkspace { get; } = new() { // First paint should expose a complete command row without forcing the operator @@ -104,10 +105,19 @@ private void TryInstallP1PanelGeometry() // P0 used row 1 directly for the command Expander. Insert a real gutter row so the // dock can use the same quiet resizer language as the Explorer. var dockRow = shell.RowDefinitions[1]; + // The workspace is the remaining star row. A fixed dock height must + // never be measured outside the window's real client area. + shell.RowDefinitions[0].Height = new GridLength(1, GridUnitType.Star); + shell.RowDefinitions[0].MinHeight = 0; + shell.ClipToBounds = true; var dockGutterRow = new RowDefinition { Height = new GridLength(0) }; shell.RowDefinitions.Insert(1, dockGutterRow); CommandPanelExpander.Margin = new Thickness(0); + CommandPanelExpander.MinHeight = 0; + CommandPanelExpander.ClipToBounds = true; + EquipmentCommandGrid.MinHeight = 0; + EquipmentCommandGrid.SetValue(ScrollViewer.VerticalScrollBarVisibilityProperty, ScrollBarVisibility.Auto); Grid.SetRow(CommandPanelExpander, 2); Grid.SetRowSpan(CommandPanelExpander, 1); @@ -172,6 +182,7 @@ private void TryInstallP1PanelGeometry() CommandPanelExpander.Collapsed += P1DockExpander_ExpandedCollapsed; CommandPanelExpander.IsVisibleChanged += P1DockExpander_IsVisibleChanged; MainTabs.SelectionChanged += P1PanelGeometry_MainTabsSelectionChanged; + shell.SizeChanged += P1DockShell_SizeChanged; Closed += P1PanelGeometry_Closed; ApplyP1DockGeometry(state, MainTabs.SelectedIndex); @@ -259,6 +270,7 @@ private void P1DockSplitter_DragCompleted(object sender, DragCompletedEventArgs state.DockHeightByWorkspace[MainTabs.SelectedIndex] = measured; } + ApplyP1DockGeometry(state, MainTabs.SelectedIndex); var target = state.DockSplitter.IsMouseOver ? 0.96 : 0; AnimateP1SplitterGuide(state.DockGuide, target, 130); } @@ -289,8 +301,19 @@ private void P1PanelGeometry_MainTabsSelectionChanged(object sender, SelectionCh })); } + private void P1DockShell_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (_p1PanelGeometry is { DockDragging: false, ApplyingGeometry: false } state && + e.HeightChanged) + ApplyP1DockGeometry(state, MainTabs.SelectedIndex); + } + private void ApplyP1DockGeometry(P1PanelGeometryState state, int workspaceIndex) { + if (state.ApplyingGeometry || state.DockDragging) return; + state.ApplyingGeometry = true; + try + { var dockActive = CommandPanelExpander.IsVisible && CommandPanelExpander.IsExpanded; if (!dockActive) { @@ -303,17 +326,25 @@ private void ApplyP1DockGeometry(P1PanelGeometryState state, int workspaceIndex) return; } - var availableHeight = Math.Max(520, _persistentWorkbench?.Shell.ActualHeight ?? ActualHeight); + // ActualHeight, never a fictitious 520px minimum. The previous floor + // could leave the dock below the visible window at low DPI/resize. + var availableHeight = _persistentWorkbench?.Shell.ActualHeight ?? ActualHeight; + if (!double.IsFinite(availableHeight) || availableHeight <= 0) return; var preferred = state.DockHeightByWorkspace.TryGetValue(workspaceIndex, out var stored) ? stored : 224d; - preferred = Math.Clamp(preferred, 126d, Math.Min(430d, availableHeight * 0.48d)); + // Keep at least 120 DIP for the live workspace and 10 for the splitter. + // On very short windows prioritize staying on-screen over dock rows. + var maxDock = Math.Max(0d, Math.Min(440d, availableHeight - 130d)); + preferred = Math.Min(Math.Max(0, preferred), maxDock); state.DockGutterRow.Height = new GridLength(10); state.DockSplitter.Visibility = Visibility.Visible; - state.DockRow.MinHeight = 112; - state.DockRow.MaxHeight = Math.Min(440, availableHeight * 0.52d); + state.DockRow.MinHeight = 0; + state.DockRow.MaxHeight = maxDock; state.DockRow.Height = new GridLength(preferred); + } + finally { state.ApplyingGeometry = false; } } private void P1PanelGeometry_Closed(object? sender, EventArgs e) @@ -331,6 +362,7 @@ private void P1PanelGeometry_Closed(object? sender, EventArgs e) CommandPanelExpander.Collapsed -= P1DockExpander_ExpandedCollapsed; CommandPanelExpander.IsVisibleChanged -= P1DockExpander_IsVisibleChanged; MainTabs.SelectionChanged -= P1PanelGeometry_MainTabsSelectionChanged; + _persistentWorkbench?.Shell.SizeChanged -= P1DockShell_SizeChanged; Closed -= P1PanelGeometry_Closed; _p1PanelGeometry = null; } From 10b3ebf73a902785dad711b8c2ebd7b8f5a910dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:55:31 +0700 Subject: [PATCH 19/63] fix(wpf): correct shell event unsubscription syntax --- MainWindow.WorkbenchP1PanelGeometry.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MainWindow.WorkbenchP1PanelGeometry.cs b/MainWindow.WorkbenchP1PanelGeometry.cs index 52fa814d3..e7bee2b91 100644 --- a/MainWindow.WorkbenchP1PanelGeometry.cs +++ b/MainWindow.WorkbenchP1PanelGeometry.cs @@ -362,7 +362,8 @@ private void P1PanelGeometry_Closed(object? sender, EventArgs e) CommandPanelExpander.Collapsed -= P1DockExpander_ExpandedCollapsed; CommandPanelExpander.IsVisibleChanged -= P1DockExpander_IsVisibleChanged; MainTabs.SelectionChanged -= P1PanelGeometry_MainTabsSelectionChanged; - _persistentWorkbench?.Shell.SizeChanged -= P1DockShell_SizeChanged; + if (_persistentWorkbench is { } workbench) + workbench.Shell.SizeChanged -= P1DockShell_SizeChanged; Closed -= P1PanelGeometry_Closed; _p1PanelGeometry = null; } From 16d7b2f5f70e1843d1aad1d0ab186369c548a726 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:56:41 +0700 Subject: [PATCH 20/63] test(browser): prove ST/MX no unit index invalidation and CF/association isolation --- .../P0Sg7BrowserPerformanceTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs diff --git a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs new file mode 100644 index 000000000..ad205f938 --- /dev/null +++ b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs @@ -0,0 +1,97 @@ +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0Sg7BrowserPerformanceTests +{ + [Fact] + public void ConfigurationRevision_DoesNotAdvanceForOrdinarySTMXOrDC_ReportValues() + { + var ledger = new IedBrowserLiveValues(); + var firstAssociation = ledger.BeginAssociation(); + var initial = ledger.ConfigurationVersion; + + Assert.True(ledger.PublishForAssociation(firstAssociation, + "SG7LD/MMXU1.A.phsA.cVal.mag.f", "MX", "123.4", "", "", "Report")); + Assert.True(ledger.PublishForAssociation(firstAssociation, + "SG7LD/XCBR1.Pos.stVal", "ST", "Open", "", "", "Report")); + Assert.True(ledger.PublishForAssociation(firstAssociation, + "SG7LD/LLN0.NamPlt.d", "DC", "BCU", "", "", "Report")); + Assert.Equal(initial, ledger.ConfigurationVersion); + + Assert.True(ledger.PublishForAssociation(firstAssociation, + "SG7LD/MMXU1.A.phsA.units.SIUnit", "CF", "A", "", "", "Report")); + Assert.Equal(initial + 1, ledger.ConfigurationVersion); + + ledger.MarkOffline(); + Assert.Equal(initial + 2, ledger.ConfigurationVersion); + Assert.False(ledger.PublishForAssociation(firstAssociation, + "SG7LD/MMXU1.A.phsA.units.multiplier", "CF", "0", "", "", "Report")); + Assert.Equal(initial + 2, ledger.ConfigurationVersion); + ledger.BeginAssociation(); + Assert.Equal(initial + 3, ledger.ConfigurationVersion); + } + + [Fact] + public void EngineeringUnitProjection_ReusesCacheForValueUpdates_ButInvalidatesMetadata() + { + var device = new Iec61850MonitorDevice(); + var unit = new SignalDefinition + { + ObjectReference = "SG7LD/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX", + Unit = "A" + }; + device.Signals.Add(unit); + var cache = new IedBrowserUnitProjectionCache(); + var evidence = new IedBrowserCfEvidencePresentation(device.LiveValues, null, null); + + var initial = cache.Resolve(device, null, evidence); + Assert.Equal("A", initial["SG7LD/MMXU1.A.phsA.cVal.mag.f|MX"]); + Assert.Same(initial, cache.Resolve(device, null, evidence)); + + var association = device.LiveValues.BeginAssociation(); + var newAssociation = cache.Resolve(device, null, evidence); + Assert.NotSame(initial, newAssociation); + Assert.True(device.LiveValues.PublishForAssociation(association, + "SG7LD/MMXU1.A.phsA.cVal.mag.f", "MX", "23", "", "", "Report")); + Assert.Same(newAssociation, cache.Resolve(device, null, evidence)); + + unit.Unit = "kA"; + var changedUnit = cache.Resolve(device, null, evidence); + Assert.NotSame(newAssociation, changedUnit); + Assert.Equal("kA", changedUnit["SG7LD/MMXU1.A.phsA.cVal.mag.f|MX"]); + + Assert.True(device.LiveValues.PublishForAssociation(association, + "SG7LD/MMXU1.A.phsA.units.SIUnit", "CF", "A", "", "", "Report")); + var changedCf = cache.Resolve(device, null, evidence); + Assert.NotSame(changedUnit, changedCf); + + device.Signals.Clear(); + var afterClear = cache.Resolve(device, null, evidence); + Assert.NotSame(changedCf, afterClear); + Assert.Empty(afterClear); + } + + [Fact] + public void UnitMetadataRevision_OnlyChangesWhenUnitChanges() + { + var signal = new SignalDefinition(); + var before = SignalDefinition.UnitMetadataRevision; + signal.Unit = "kV"; + Assert.True(SignalDefinition.UnitMetadataRevision > before); + var after = SignalDefinition.UnitMetadataRevision; + signal.Unit = "kV"; + Assert.Equal(after, SignalDefinition.UnitMetadataRevision); + } + + [Fact] + public void BrowserPerformanceSource_ExistsWithoutRequiringAUiOrNetworkSession() + { + Assert.Equal("ARSAS-IEDBrowser-Performance", + System.Diagnostics.Tracing.EventSource.GetSources() + .Select(source => source.Name) + .FirstOrDefault(name => name == "ARSAS-IEDBrowser-Performance") + ?? "ARSAS-IEDBrowser-Performance"); + } +} From b5a9522fbb3fdbcca7decd24b9e15a60a5738743 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:56:59 +0700 Subject: [PATCH 21/63] test(browser): assert actual EventSource provider attribute instead of tautology --- tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs index ad205f938..f4011fa10 100644 --- a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs +++ b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs @@ -88,10 +88,12 @@ public void UnitMetadataRevision_OnlyChangesWhenUnitChanges() [Fact] public void BrowserPerformanceSource_ExistsWithoutRequiringAUiOrNetworkSession() { + var type = typeof(IedBrowserUnitProjectionCache).Assembly.GetType( + "ArIED61850Tester.Services.IedBrowserPerformanceEvents"); + Assert.NotNull(type); + var attribute = Assert.Single(type.GetCustomAttributes( + typeof(System.Diagnostics.Tracing.EventSourceAttribute), false)); Assert.Equal("ARSAS-IEDBrowser-Performance", - System.Diagnostics.Tracing.EventSource.GetSources() - .Select(source => source.Name) - .FirstOrDefault(name => name == "ARSAS-IEDBrowser-Performance") - ?? "ARSAS-IEDBrowser-Performance"); + ((System.Diagnostics.Tracing.EventSourceAttribute)attribute).Name); } } From 769750a43ee6fa39c11a3b21de0272b62af5b9e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:58:20 +0700 Subject: [PATCH 22/63] perf(browser): scope units timing strictly to cache lookup/build --- MainWindow.IedBrowser.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 5ee77f493..68e8c143c 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1033,9 +1033,12 @@ private void RefreshIedBrowserVisibleValues() else IedBrowserSubtitle.ToolTip = "Offline/SCL declarations are not proof of fresh IEC MMS observations."; - using var unitsPerf = IedBrowserPerformanceProbe.Measure( - "Browser.Units", device.Name, device.Signals.Count); - var engineeringUnits = _iedBrowserUnitCache.Resolve(device, model, cfEvidence); + IReadOnlyDictionary engineeringUnits; + using (IedBrowserPerformanceProbe.Measure( + "Browser.Units", device.Name, device.Signals.Count)) + { + engineeringUnits = _iedBrowserUnitCache.Resolve(device, model, cfEvidence); + } foreach (var row in _iedBrowserDetails.Rows) { if (string.IsNullOrWhiteSpace(row.ValueReference)) continue; From a2bce74b501037d595eb6f6b947e7e8a0df7ebc6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:59:54 +0700 Subject: [PATCH 23/63] test(browser): ensure isolated unit-generation regression and explicit metadata retry --- MainWindow.IedBrowser.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 68e8c143c..7e03eef6e 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -216,7 +216,17 @@ private void IedBrowserBrowseFiles_Click(object sender, RoutedEventArgs e) private void IedBrowserRefresh_Click(object sender, RoutedEventArgs e) { - // The operator explicitly requested a new local projection, not MMS discovery. + // Explicit local refresh may retry previously faulted background metadata + // builds; ordinary live ticks never create retry storms. + if (SelectedDevice is { } selected) + { + var model = selected.LiveDiscoveryModel ?? selected.SclWorkspace?.DesignModel; + if (model is not null && _iedBrowserSchemaTasks.TryGetValue(model, out var indexTask) && + indexTask.IsFaulted) + _iedBrowserSchemaTasks.Remove(model); + } + if (_iedBrowserInventoryTask?.IsFaulted == true) + _iedBrowserInventoryTask = null; _iedBrowserForceRefresh = true; RefreshIedBrowser(); } From 859a9f0129879f254ea6b62486cd0de3d15f7dec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 07:59:58 +0700 Subject: [PATCH 24/63] test(browser): ensure isolated unit-generation regression and explicit metadata retry --- tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs index f4011fa10..c82dd64d1 100644 --- a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs +++ b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs @@ -2,6 +2,10 @@ namespace ARSAS.Tests; +[CollectionDefinition("SG7-Unit-Metadata-Serial", DisableParallelization = true)] +public sealed class P0Sg7BrowserPerformanceTestCollection; + +[Collection("SG7-Unit-Metadata-Serial")] public sealed class P0Sg7BrowserPerformanceTests { [Fact] From 235b36a1b6678737d824f98e7a9aac680ef3ea85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:00:13 +0700 Subject: [PATCH 25/63] test(browser): use compatible explicit xUnit collection declaration --- tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs index c82dd64d1..7a90dfdbd 100644 --- a/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs +++ b/tests/ARSAS.Tests/P0Sg7BrowserPerformanceTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; [CollectionDefinition("SG7-Unit-Metadata-Serial", DisableParallelization = true)] -public sealed class P0Sg7BrowserPerformanceTestCollection; +public sealed class P0Sg7BrowserPerformanceTestCollection { } [Collection("SG7-Unit-Metadata-Serial")] public sealed class P0Sg7BrowserPerformanceTests From f20587e1bfca1c9ccdd78835a8535349dae6e0b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:05:41 +0700 Subject: [PATCH 26/63] test(browser): validate CF-aware cached projection instead of obsolete direct hot-path call --- tests/ARSAS.Tests/IedBrowserCfEvidenceM213JTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IedBrowserCfEvidenceM213JTests.cs b/tests/ARSAS.Tests/IedBrowserCfEvidenceM213JTests.cs index ff060e2c0..609252365 100644 --- a/tests/ARSAS.Tests/IedBrowserCfEvidenceM213JTests.cs +++ b/tests/ARSAS.Tests/IedBrowserCfEvidenceM213JTests.cs @@ -226,7 +226,14 @@ public void BrowserRefreshBindsCfEvidenceWithoutImplicitMmsRequests() Assert.NotNull(cursor); var source = File.ReadAllText( Path.Combine(cursor!.FullName, "MainWindow.IedBrowser.cs")); - Assert.Contains("cfEvidence.BuildVerifiedUnits(device.Signals, model)", source); + // The semantic bridge must still be used, but no longer rescan all + // SG7 signals directly on every WPF value refresh. + Assert.Contains("_iedBrowserUnitCache.Resolve(device, model, cfEvidence)", source); + var cache = File.ReadAllText(Path.Combine(cursor.FullName, + "Models", "IedBrowserUnitProjectionCache.cs")); + Assert.Contains("evidence.BuildVerifiedUnits(device.Signals, model)", cache); + Assert.Contains("device.LiveValues.ConfigurationVersion", cache); + Assert.Contains("SignalDefinition.UnitMetadataRevision", cache); Assert.Contains("IedBrowserCfEvidencePresentation.RenderCfValue(", source); Assert.Contains("Discovery snapshot", source); } From 341bf18a7003a9fc3508c4bd389d8e6e0f86c0d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:16:25 +0700 Subject: [PATCH 27/63] feat(ip): project verified connection metadata from existing preference store --- Services/UserPreferenceStore.cs | 64 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/Services/UserPreferenceStore.cs b/Services/UserPreferenceStore.cs index e874da83f..193886a65 100644 --- a/Services/UserPreferenceStore.cs +++ b/Services/UserPreferenceStore.cs @@ -68,27 +68,67 @@ internal static void RecordLastDiscoveryEndpoint(string path, string ipAddress, File.WriteAllText(path, JsonSerializer.Serialize(prefs, Options)); } + public sealed record RecentEndpointDetail( + string IpAddress, int MmsPort, string IedName, bool ConfirmedSuccessful) + { + public string Subtitle => ConfirmedSuccessful + ? $"Connected successfully · {IedName} · MMS {MmsPort}" + : "Saved address · connection not verified"; + } + + // A read-only projection of the EXISTING user preference document. + // Discovery submissions and connection successes remain separate facts; + // opening this picker never writes or attempts a connection. + public static IReadOnlyList LoadRecentEndpointDetails(int maximum = 20) + => DescribeRecentEndpoints(LoadPreferences(), maximum); + + internal static IReadOnlyList LoadRecentEndpointDetails( + string preferencesPath, int maximum = 20) + => DescribeRecentEndpoints(ReadPreferencesFile(preferencesPath) ?? new UserPreferences(), maximum); + + private static IReadOnlyList DescribeRecentEndpoints( + UserPreferences preferences, int maximum) + { + var successes = preferences.SuccessfulRelays + .Where(e => !string.IsNullOrWhiteSpace(e.IpAddress) && + IPAddress.TryParse(e.IpAddress.Trim(), out _) && + e.MmsPort is > 0 and <= 65535) + .OrderByDescending(e => e.LastConnectedUtc) + .ToArray(); + + return GetRecentIpCandidates(preferences, maximum) + .Select(ip => + { + var success = successes.FirstOrDefault(e => + string.Equals(e.IpAddress.Trim(), ip, StringComparison.OrdinalIgnoreCase)); + return success is null + ? new RecentEndpointDetail(ip, 102, string.Empty, false) + : new RecentEndpointDetail(ip, success.MmsPort, + success.IedName ?? string.Empty, true); + }) + .ToArray(); + } + public static IReadOnlyList LoadRecentEndpoints(int maximum = 20) + => GetRecentIpCandidates(LoadPreferences(), maximum); + + private static IReadOnlyList GetRecentIpCandidates(UserPreferences prefs, int maximum) { maximum = Math.Clamp(maximum, 1, 100); - var prefs = LoadPreferences(); return new[] { prefs.LastDiscoveryIpAddress } - .Where(ip => !string.IsNullOrWhiteSpace(ip) && IPAddress.TryParse(ip.Trim(), out _)) - .Select(ip => ip.Trim()) .Concat(prefs.SuccessfulRelays - .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) - .OrderByDescending(item => item.LastConnectedUtc) - .Select(item => item.IpAddress.Trim())) + .OrderByDescending(item => item.LastConnectedUtc) + .Select(item => item.IpAddress)) .Concat(prefs.SignalSelectionProfiles - .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) .OrderByDescending(item => item.LastSavedUtc) - .Select(item => item.IpAddress.Trim())) - .Concat(prefs.RecentRelayIps - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Select(item => item.Trim())) + .Select(item => item.IpAddress)) + .Concat(prefs.RecentRelayIps) + .Where(ip => !string.IsNullOrWhiteSpace(ip) && + IPAddress.TryParse(ip.Trim(), out _)) + .Select(ip => ip.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(maximum) - .ToList(); + .ToArray(); } public static void RecordSuccessfulEndpoint(string ipAddress, int port, string iedName) From eb4e63308a4c0c4dffaea846208a1a1f6f98141d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:17:44 +0700 Subject: [PATCH 28/63] feat(ip): compact segmented IPv4 editor and searchable connection history flyout --- IpConnectWizardWindow.xaml | 194 +++++++++++++++++++++++++++---------- 1 file changed, 144 insertions(+), 50 deletions(-) diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml index 44e76c3e6..4518ee4f8 100644 --- a/IpConnectWizardWindow.xaml +++ b/IpConnectWizardWindow.xaml @@ -1,7 +1,7 @@ + + - @@ -22,55 +46,127 @@ - + Text="Enter a device address to discover its IEC 61850 data model."/> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + +