From 94e5ba8391b1652569a52bfc8a5b14486aa37834 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:33:14 +0700 Subject: [PATCH 001/184] feat(m217l): bounded on-selection SGCB and LN evidence hydration using existing safe MMS read path --- MainWindow.IedBrowser.cs | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 0b338bbff..fe6c32c3e 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -263,6 +263,35 @@ private async void IedBrowserReadRcb_Click(object sender, RoutedEventArgs e) private void IedBrowserCancelRead_Click(object sender, RoutedEventArgs e) => CancelIedBrowserRead(); + // One finite read per selected live association/page. Report ingress remains + // authoritative; this set is only a bounded navigation admission ledger. + // No polling timer, process-value mirror or write service is introduced. + private readonly HashSet _iedBrowserAutoReadPages = new(StringComparer.Ordinal); + + private void QueueIedBrowserFocusedRead(IedBrowserTreeRow? page) + { + if (page?.Kind is not ("SGCB" or "LN") || + SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false } device || + _iedBrowserReadOperation is not null || device.IsBrowserReading || + device.LiveDiscoveryModel is null || device.LiveValues.IsOffline) + return; + // RCB, DataSet and bulk inventory navigation stay entirely passive. + // A selected LN gets just a small exact ST/MX read image; SGCB also + // admits the verified read-only NumOfSG/ActSG/LastChange fields. + var key = device.LiveValues.Association + "|" + device.Name + "|" + page.Key; + if (!_iedBrowserAutoReadPages.Add(key)) return; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(_iedBrowserNavigation.Current, page) || + !device.IsConnected || device.IsBusy || + _iedBrowserReadOperation is not null) + return; + IedBrowserReadValues_Click(new Button { Tag = "FocusedAuto" }, + new RoutedEventArgs()); + })); + } + private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) { var device = SelectedDevice; @@ -273,6 +302,7 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // Its bounded reads never interfere with the reporting session. var readAll = sender is Button { Tag: "All" }; + var focusedAuto = sender is Button { Tag: "FocusedAuto" }; if (!readAll) { switch (IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current)) @@ -299,6 +329,23 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) ? _iedBrowserSelectedDoReference : null; var targets = IedBrowserReadPlan.Build( model, currentPage, readAll, selectedDo); + if (focusedAuto) + { + // Never start a large LN or SG Read All implicitly. Keep the + // same existing isolated MMS read service and result inbox. + // ST process leaves are preferred for the Data Model; SGCB control + // state gets priority before settings within the finite envelope. + const int maxFocusedTargets = 12; + targets = currentPage?.Kind == "SGCB" + ? targets.OrderBy(t => t.FunctionalConstraint == "SG" ? 1 : 0) + .ThenBy(t => t.Reference, StringComparer.Ordinal) + .Take(maxFocusedTargets).ToArray() + : targets.Where(t => t.FunctionalConstraint is "ST" or "MX") + .OrderBy(t => t.Reference.Contains("$ST$", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(t => t.Reference.EndsWith("$stVal", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(t => t.Reference, StringComparer.Ordinal) + .Take(maxFocusedTargets).ToArray(); + } if (targets.Count == 0) { IedBrowserSubtitle.Text = "No model-verified readable FC attributes in this selection."; @@ -314,6 +361,8 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // connection and reject a previously planned schema. IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + if (focusedAuto) + cts.CancelAfter(TimeSpan.FromSeconds(8)); // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. _iedBrowserReadOperation = cts; @@ -1692,6 +1741,7 @@ private void RenderIedBrowserSelection( _iedBrowserLastLiveVersion = -1; RefreshIedBrowserVisibleValues(); RefreshIedBrowserReadButtons(); + QueueIedBrowserFocusedRead(row); // Folder entry opens at its first child, regardless of the scroll // position in the previously inspected RCB, LN or DataSet. if (_iedBrowserDetails.Rows.Count > 0) From 4dcc3556312a4e343c82df3b0a649c642b73da58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:33:37 +0700 Subject: [PATCH 002/184] test(m217l): guard finite live SG/LN hydration and strict verified FC/association policy --- .../M217LBrowserFocusedHydrationTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs new file mode 100644 index 000000000..4c937825d --- /dev/null +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -0,0 +1,51 @@ +using ArIED61850Tester.Models; +using AR.Iec61850.Discovery; + +namespace ARSAS.Tests; + +/// Focused Browser acquisition is finite, read-only and reuses the +/// already verified live FC inventory; no Read All or shadow value source. +public sealed class M217LBrowserFocusedHydrationTests +{ + [Fact] + public void FocusedHydrationSourceUsesExistingIsolatedReadAndCurrentAssociation() + { + var source = Read("MainWindow.IedBrowser.cs"); + Assert.Contains("QueueIedBrowserFocusedRead(row)", source); + Assert.Contains("Tag = \"FocusedAuto\"", source); + Assert.Contains("const int maxFocusedTargets = 12", source); + Assert.Contains("cts.CancelAfter(TimeSpan.FromSeconds(8))", source); + Assert.Contains("device.LiveDiscoveryModel is null", source); + Assert.Contains("device.LiveValues.Association", source); + Assert.Contains("IedBrowserReadPlan.Build(", source); + Assert.Contains("IedBrowserIsolatedReadService.ReadAsync(", source); + Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", source); + Assert.DoesNotContain("Tag = \"All\" },", source); + } + + [Fact] + public void VerifiedSgControlStateRetainsStrictReadOnlyFcInventory() + { + var control = Read("Models/IedBrowserControlStateEvidence.cs"); + Assert.Contains("NumOfSG", control); + Assert.Contains("ActSG", control); + Assert.Contains("LActTm", control); + var plan = Read("Models/IedBrowserReadPlan.cs"); + Assert.Contains("IedBrowserControlStateEvidence.ReadTargets", plan); + var browser = Read("MainWindow.IedBrowser.cs"); + Assert.Contains("currentPage?.Kind == \"SGCB\"", browser); + Assert.Contains("t.FunctionalConstraint == \"SG\" ? 1 : 0", browser); + Assert.Contains("targets.Where(t => t.FunctionalConstraint is \"ST\" or \"MX\")", browser); + } + + private static string Read(string path) + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, + "engines", "ARIEC61850.lock.json"))) + root = root.Parent; + Assert.NotNull(root); + return File.ReadAllText(Path.Combine(root!.FullName, + path.Replace('/', Path.DirectorySeparatorChar))); + } +} From 1ae22c9aada50e04db1bf10d333061e92e46361e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:34:45 +0700 Subject: [PATCH 003/184] feat(m217l): derive strictly bound CF SIUnit/multiplier reads from selected live MX DataSet --- Models/IedBrowserReadPlan.cs | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 69727352e..f8eb44c8b 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -117,6 +117,65 @@ page.Kind is "SGCB" or "GoCB" .ToArray(); } + /// + /// Strict, finite CF-unit evidence for the selected live MX DataSet. + /// The two standard leaves (SIUnit/multiplier) must belong to the EXACT + /// MX measurement object covered by this live DataSet. A nearby LN, + /// separate FC or SCL-only declaration is never authority to read. + /// This returns only a read plan, not a unit/value guess. + /// + public static IReadOnlyList BuildDataSetUnitRead( + LiveIedModelDiscoveryDocument? verifiedLiveModel, + LiveIedDataSetModel? selectedSet, + IedBrowserLiveValues ledger, int maxTargets = 12) + { + if (verifiedLiveModel is null || selectedSet is null || + maxTargets < 2 || ledger.IsOffline || + !verifiedLiveModel.DataSets.Any(ds => + ReferenceEquals(ds, selectedSet))) + return Array.Empty(); + + var schema = new IedBrowserSchemaIndex(verifiedLiveModel); + var association = ledger.Association; + var found = new List(maxTargets); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var obj in verifiedLiveModel.LogicalDevices.SelectMany( + ld => ld.LogicalNodes).SelectMany(ln => ln.DataObjects)) + { + if (found.Count + 2 > maxTargets) break; + var objectRef = IedBrowserLiveValues.CanonicalReference(obj.Reference); + var measurements = obj.Attributes.Where(attr => + attr.FunctionalConstraint == "MX" && + (IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + .EndsWith(".mag.f", StringComparison.Ordinal))); + if (!measurements.Any(attr => schema.ContainsDataSetMember( + selectedSet.Reference, + IedBrowserLiveValues.CanonicalReference(attr.MmsReference), + "MX"))) + continue; + var expectedSi = objectRef + ".units.SIUnit"; + var expectedMul = objectRef + ".units.multiplier"; + var cf = obj.Attributes.Where(a => a.FunctionalConstraint == "CF") + .GroupBy(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference), + StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.Ordinal); + if (!cf.TryGetValue(expectedSi, out var si) || si.Length != 1 || + !cf.TryGetValue(expectedMul, out var mul) || mul.Length != 1) + continue; + foreach (var attr in new[] { si[0], mul[0] }) + { + var key = IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + "|CF"; + if (!seen.Add(key)) continue; + if (ledger.TryGet(attr.MmsReference, "CF", out var observed) && + !observed.IsStale && observed.Association == association) + continue; + found.Add(new IedBrowserReadTarget(attr.MmsReference, "CF", + string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType)); + } + } + return found; + } + /// /// Finite, evidence-first initial acquisition for a freshly discovered /// LIVE MMS schema. This is not Browser Read All or a new polling plan: From 3c9471f4f417a20480b5b044e593ace3123b10ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:35:03 +0700 Subject: [PATCH 004/184] feat(m217l): lazily acquire paired CF engineering units for exact live DataSet measurements --- MainWindow.IedBrowser.cs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index fe6c32c3e..d9900f5cf 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -270,14 +270,14 @@ private void IedBrowserCancelRead_Click(object sender, RoutedEventArgs e) private void QueueIedBrowserFocusedRead(IedBrowserTreeRow? page) { - if (page?.Kind is not ("SGCB" or "LN") || + if (page?.Kind is not ("SGCB" or "LN" or "DataSet") || SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false } device || _iedBrowserReadOperation is not null || device.IsBrowserReading || device.LiveDiscoveryModel is null || device.LiveValues.IsOffline) return; - // RCB, DataSet and bulk inventory navigation stay entirely passive. - // A selected LN gets just a small exact ST/MX read image; SGCB also - // admits the verified read-only NumOfSG/ActSG/LastChange fields. + // RCB and bulk inventory navigation stay entirely passive. + // LN gets an exact ST/MX image, SGCB its verified control state, + // and DataSet only the declared CF SIUnit/multiplier pair. var key = device.LiveValues.Association + "|" + device.Name + "|" + page.Key; if (!_iedBrowserAutoReadPages.Add(key)) return; Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => @@ -327,8 +327,10 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) ReferenceEquals(_iedBrowserRenderedPage, currentPage) && currentPage?.Payload is LiveIedLogicalNodeModel ? _iedBrowserSelectedDoReference : null; - var targets = IedBrowserReadPlan.Build( - model, currentPage, readAll, selectedDo); + var targets = focusedAuto && currentPage?.Payload is LiveIedDataSetModel unitSet + ? IedBrowserReadPlan.BuildDataSetUnitRead( + device.LiveDiscoveryModel, unitSet, device.LiveValues) + : IedBrowserReadPlan.Build(model, currentPage, readAll, selectedDo); if (focusedAuto) { // Never start a large LN or SG Read All implicitly. Keep the @@ -336,11 +338,13 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // ST process leaves are preferred for the Data Model; SGCB control // state gets priority before settings within the finite envelope. const int maxFocusedTargets = 12; - targets = currentPage?.Kind == "SGCB" - ? targets.OrderBy(t => t.FunctionalConstraint == "SG" ? 1 : 0) - .ThenBy(t => t.Reference, StringComparer.Ordinal) - .Take(maxFocusedTargets).ToArray() - : targets.Where(t => t.FunctionalConstraint is "ST" or "MX") + targets = currentPage?.Payload is LiveIedDataSetModel + ? targets.Take(maxFocusedTargets).ToArray() + : currentPage?.Kind == "SGCB" + ? targets.OrderBy(t => t.FunctionalConstraint == "SG" ? 1 : 0) + .ThenBy(t => t.Reference, StringComparer.Ordinal) + .Take(maxFocusedTargets).ToArray() + : targets.Where(t => t.FunctionalConstraint is "ST" or "MX") .OrderBy(t => t.Reference.Contains("$ST$", StringComparison.Ordinal) ? 0 : 1) .ThenBy(t => t.Reference.EndsWith("$stVal", StringComparison.Ordinal) ? 0 : 1) .ThenBy(t => t.Reference, StringComparer.Ordinal) From 4b851e41baf7132cd8874bff51bea24d8fc47861 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:35:20 +0700 Subject: [PATCH 005/184] test(m217l): assert DataSet-only CF unit read route stays finite and association-bound --- tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs index 4c937825d..6278f9a8b 100644 --- a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -19,6 +19,9 @@ public void FocusedHydrationSourceUsesExistingIsolatedReadAndCurrentAssociation( Assert.Contains("device.LiveValues.Association", source); Assert.Contains("IedBrowserReadPlan.Build(", source); Assert.Contains("IedBrowserIsolatedReadService.ReadAsync(", source); + Assert.Contains("IedBrowserReadPlan.BuildDataSetUnitRead(", source); + Assert.Contains("currentPage?.Payload is LiveIedDataSetModel unitSet", source); + Assert.Contains("page?.Kind is not (\"SGCB\" or \"LN\" or \"DataSet\")", source); Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", source); Assert.DoesNotContain("Tag = \"All\" },", source); } From ab73d09fc149c2ae9d29f74476a23df3732f4000 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:40:08 +0700 Subject: [PATCH 006/184] test(m217l): behavioral live-MX DataSet CF pair selection, missing half, FC and model fencing --- .../M217LBrowserFocusedHydrationTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs index 6278f9a8b..015f5a239 100644 --- a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -41,6 +41,86 @@ public void VerifiedSgControlStateRetainsStrictReadOnlyFcInventory() Assert.Contains("targets.Where(t => t.FunctionalConstraint is \"ST\" or \"MX\")", browser); } + [Fact] + public void ExactLiveMeasurementDatasetPlansOnlyDeclaredPairedCfUnits() + { + const string owner = "LD/MMXU1.A"; + static LiveIedDataAttributeModel Attribute(string fc, string suffix) => new() + { + ObjectReference = owner, + AttributePath = "A." + suffix, + MmsReference = "LD/MMXU1$" + fc + "$A$" + suffix.Replace('.', ' + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, + "engines", "ARIEC61850.lock.json"))) + root = root.Parent; + Assert.NotNull(root); + return File.ReadAllText(Path.Combine(root!.FullName, + path.Replace('/', Path.DirectorySeparatorChar))); + } +} +), + FunctionalConstraint = fc, + SclBType = "Enum" + }; + var obj = new LiveIedDataObjectModel + { + Name = "A", Reference = owner, InferredCdc = "WYE", + Attributes = + [ + Attribute("MX", "phsA.cVal.mag.f"), + Attribute("CF", "units.SIUnit"), + Attribute("CF", "units.multiplier") + ] + }; + var set = new LiveIedDataSetModel + { + Name = "Measurements", Reference = "LD/LLN0.Measurements", + Domain = "LD", LogicalNode = "LLN0", + Members = [new LiveIedDataSetMemberModel + { + Reference = owner, FunctionalConstraint = "MX" + }] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", DataObjects = [obj] + }] + }], + DataSets = [set] + }; + var values = new IedBrowserLiveValues(); + values.BeginAssociation(); + var targets = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, values); + Assert.Equal(2, targets.Count); + Assert.All(targets, target => Assert.Equal("CF", target.FunctionalConstraint)); + Assert.Contains(targets, target => target.Reference.EndsWith("$units$SIUnit", StringComparison.Ordinal)); + Assert.Contains(targets, target => target.Reference.EndsWith("$units$multiplier", StringComparison.Ordinal)); + var association = values.Association; + Assert.True(values.PublishForAssociation(association, + owner + ".units.SIUnit", "CF", "5", "", "", "MMS Read")); + var missing = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, values); + Assert.Single(missing); + Assert.EndsWith("$units$multiplier", missing[0].Reference); + + // A mismatched object/FC is not authorized by the selected set. + var another = new LiveIedDataSetModel + { + Name = "Wrong", Reference = "LD/LLN0.Wrong", + Domain = "LD", LogicalNode = "LLN0", + Members = [new LiveIedDataSetMemberModel + { + Reference = owner, FunctionalConstraint = "ST" + }] + }; + Assert.Empty(IedBrowserReadPlan.BuildDataSetUnitRead(model, another, values)); + } + private static string Read(string path) { DirectoryInfo? root = new(AppContext.BaseDirectory); From 9845c4a4c319959179ff693041ec76c96e04ebd6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:42:03 +0700 Subject: [PATCH 007/184] test(m217l): repair malformed test fixture and assert live CF-unit selection behavior --- .../M217LBrowserFocusedHydrationTests.cs | 73 ++++++++----------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs index 015f5a239..0cbd12e05 100644 --- a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -1,16 +1,18 @@ -using ArIED61850Tester.Models; using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; namespace ARSAS.Tests; -/// Focused Browser acquisition is finite, read-only and reuses the -/// already verified live FC inventory; no Read All or shadow value source. +/// +/// Exact, finite selection of MMS evidence for SGCB, LN and MX DataSet unit CF. +/// This does not manufacture process values or authorize writes. +/// public sealed class M217LBrowserFocusedHydrationTests { [Fact] - public void FocusedHydrationSourceUsesExistingIsolatedReadAndCurrentAssociation() + public void FocusedHydrationUsesAssociationBoundExistingReadPath() { - var source = Read("MainWindow.IedBrowser.cs"); + var source = ReadSource("MainWindow.IedBrowser.cs"); Assert.Contains("QueueIedBrowserFocusedRead(row)", source); Assert.Contains("Tag = \"FocusedAuto\"", source); Assert.Contains("const int maxFocusedTargets = 12", source); @@ -18,57 +20,43 @@ public void FocusedHydrationSourceUsesExistingIsolatedReadAndCurrentAssociation( Assert.Contains("device.LiveDiscoveryModel is null", source); Assert.Contains("device.LiveValues.Association", source); Assert.Contains("IedBrowserReadPlan.Build(", source); - Assert.Contains("IedBrowserIsolatedReadService.ReadAsync(", source); Assert.Contains("IedBrowserReadPlan.BuildDataSetUnitRead(", source); - Assert.Contains("currentPage?.Payload is LiveIedDataSetModel unitSet", source); - Assert.Contains("page?.Kind is not (\"SGCB\" or \"LN\" or \"DataSet\")", source); + Assert.Contains("IedBrowserIsolatedReadService.ReadAsync(", source); Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", source); - Assert.DoesNotContain("Tag = \"All\" },", source); + Assert.Contains("page?.Kind is not (\"SGCB\" or \"LN\" or \"DataSet\")", source); } [Fact] - public void VerifiedSgControlStateRetainsStrictReadOnlyFcInventory() + public void VerifiedSgAndDataModelPathsKeepTypedFcBoundaries() { - var control = Read("Models/IedBrowserControlStateEvidence.cs"); + var control = ReadSource("Models/IedBrowserControlStateEvidence.cs"); Assert.Contains("NumOfSG", control); Assert.Contains("ActSG", control); Assert.Contains("LActTm", control); - var plan = Read("Models/IedBrowserReadPlan.cs"); + var plan = ReadSource("Models/IedBrowserReadPlan.cs"); Assert.Contains("IedBrowserControlStateEvidence.ReadTargets", plan); - var browser = Read("MainWindow.IedBrowser.cs"); + var browser = ReadSource("MainWindow.IedBrowser.cs"); Assert.Contains("currentPage?.Kind == \"SGCB\"", browser); Assert.Contains("t.FunctionalConstraint == \"SG\" ? 1 : 0", browser); Assert.Contains("targets.Where(t => t.FunctionalConstraint is \"ST\" or \"MX\")", browser); } [Fact] - public void ExactLiveMeasurementDatasetPlansOnlyDeclaredPairedCfUnits() + public void ExactLiveMxDataSetPlansOnlyPairedCfUnitReads() { const string owner = "LD/MMXU1.A"; - static LiveIedDataAttributeModel Attribute(string fc, string suffix) => new() + LiveIedDataAttributeModel Attribute(string fc, string suffix) => new() { ObjectReference = owner, AttributePath = "A." + suffix, - MmsReference = "LD/MMXU1$" + fc + "$A$" + suffix.Replace('.', ' - { - DirectoryInfo? root = new(AppContext.BaseDirectory); - while (root is not null && !File.Exists(Path.Combine(root.FullName, - "engines", "ARIEC61850.lock.json"))) - root = root.Parent; - Assert.NotNull(root); - return File.ReadAllText(Path.Combine(root!.FullName, - path.Replace('/', Path.DirectorySeparatorChar))); - } -} -), + MmsReference = "LD/MMXU1$" + fc + "$A$" + suffix.Replace('.', '$'), FunctionalConstraint = fc, SclBType = "Enum" }; var obj = new LiveIedDataObjectModel { Name = "A", Reference = owner, InferredCdc = "WYE", - Attributes = - [ + Attributes = [ Attribute("MX", "phsA.cVal.mag.f"), Attribute("CF", "units.SIUnit"), Attribute("CF", "units.multiplier") @@ -94,22 +82,19 @@ public void ExactLiveMeasurementDatasetPlansOnlyDeclaredPairedCfUnits() }], DataSets = [set] }; - var values = new IedBrowserLiveValues(); - values.BeginAssociation(); - var targets = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, values); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var targets = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, ledger); Assert.Equal(2, targets.Count); Assert.All(targets, target => Assert.Equal("CF", target.FunctionalConstraint)); - Assert.Contains(targets, target => target.Reference.EndsWith("$units$SIUnit", StringComparison.Ordinal)); - Assert.Contains(targets, target => target.Reference.EndsWith("$units$multiplier", StringComparison.Ordinal)); - var association = values.Association; - Assert.True(values.PublishForAssociation(association, + Assert.Contains(targets, t => t.Reference.EndsWith("$units$SIUnit", StringComparison.Ordinal)); + Assert.Contains(targets, t => t.Reference.EndsWith("$units$multiplier", StringComparison.Ordinal)); + Assert.True(ledger.PublishForAssociation(association, owner + ".units.SIUnit", "CF", "5", "", "", "MMS Read")); - var missing = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, values); + var missing = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, ledger); Assert.Single(missing); Assert.EndsWith("$units$multiplier", missing[0].Reference); - - // A mismatched object/FC is not authorized by the selected set. - var another = new LiveIedDataSetModel + var other = new LiveIedDataSetModel { Name = "Wrong", Reference = "LD/LLN0.Wrong", Domain = "LD", LogicalNode = "LLN0", @@ -118,14 +103,14 @@ public void ExactLiveMeasurementDatasetPlansOnlyDeclaredPairedCfUnits() Reference = owner, FunctionalConstraint = "ST" }] }; - Assert.Empty(IedBrowserReadPlan.BuildDataSetUnitRead(model, another, values)); + Assert.Empty(IedBrowserReadPlan.BuildDataSetUnitRead(model, other, ledger)); } - private static string Read(string path) + private static string ReadSource(string path) { DirectoryInfo? root = new(AppContext.BaseDirectory); - while (root is not null && !File.Exists(Path.Combine(root.FullName, - "engines", "ARIEC61850.lock.json"))) + while (root is not null && + !File.Exists(Path.Combine(root.FullName, "MainWindow.IedBrowser.cs"))) root = root.Parent; Assert.NotNull(root); return File.ReadAllText(Path.Combine(root!.FullName, From 42d8b23c39a3b2373cd2dfacc86d6ca9c0e7f55f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:46:10 +0700 Subject: [PATCH 008/184] feat(m217l): hydrate exact focused Data Model DO independently of initial LN budget --- MainWindow.IedBrowser.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d9900f5cf..1e453c43d 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -268,7 +268,8 @@ private void IedBrowserCancelRead_Click(object sender, RoutedEventArgs e) // No polling timer, process-value mirror or write service is introduced. private readonly HashSet _iedBrowserAutoReadPages = new(StringComparer.Ordinal); - private void QueueIedBrowserFocusedRead(IedBrowserTreeRow? page) + private void QueueIedBrowserFocusedRead(IedBrowserTreeRow? page, + string? focusedObjectReference = null) { if (page?.Kind is not ("SGCB" or "LN" or "DataSet") || SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false } device || @@ -278,7 +279,8 @@ _iedBrowserReadOperation is not null || device.IsBrowserReading || // RCB and bulk inventory navigation stay entirely passive. // LN gets an exact ST/MX image, SGCB its verified control state, // and DataSet only the declared CF SIUnit/multiplier pair. - var key = device.LiveValues.Association + "|" + device.Name + "|" + page.Key; + var key = device.LiveValues.Association + "|" + device.Name + "|" + + page.Key + "|" + (focusedObjectReference ?? ""); if (!_iedBrowserAutoReadPages.Add(key)) return; Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { @@ -1564,6 +1566,11 @@ private void IedBrowserDetailRow_MouseUp( { _iedBrowserSelectedDoReference = detail.ReadObjectReference; RefreshIedBrowserReadButtons(); // expose separate Read DO and Read LN scope + // The selected DO is independently eligible for a small exact + // read; do not leave an otherwise unobserved XCBR status at + // Not read merely because another DO used the LN's first budget. + QueueIedBrowserFocusedRead(_iedBrowserNavigation.Current, + detail.ReadObjectReference); } if (!detail.HasChildren) return; ToggleIedBrowserDetails(detail); From f4182556abb79f7400c5e2cc6e0ed9efbdbfa3c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:46:25 +0700 Subject: [PATCH 009/184] perf(m217l): bound navigation admission history for large IEC 61850 models --- MainWindow.IedBrowser.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 1e453c43d..ccc6cf9ad 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -281,6 +281,10 @@ _iedBrowserReadOperation is not null || device.IsBrowserReading || // and DataSet only the declared CF SIUnit/multiplier pair. var key = device.LiveValues.Association + "|" + device.Name + "|" + page.Key + "|" + (focusedObjectReference ?? ""); + // A very large IED may have thousands of LNs/DOs. Keep only a + // small admission history; it is not a process-value cache. + if (_iedBrowserAutoReadPages.Count >= 128) + _iedBrowserAutoReadPages.Clear(); if (!_iedBrowserAutoReadPages.Add(key)) return; Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { From 53c3d7ecce49cf94850291aa27b2e9fecaa300a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:46:38 +0700 Subject: [PATCH 010/184] test(m217l): assert DO-scoped browser hydration and bounded navigation admission history --- tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs index 0cbd12e05..d4d2c14a3 100644 --- a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -14,6 +14,9 @@ public void FocusedHydrationUsesAssociationBoundExistingReadPath() { var source = ReadSource("MainWindow.IedBrowser.cs"); Assert.Contains("QueueIedBrowserFocusedRead(row)", source); + Assert.Contains("QueueIedBrowserFocusedRead(_iedBrowserNavigation.Current,", source); + Assert.Contains("page.Key + \"|\" + (focusedObjectReference ?? \"\")", source); + Assert.Contains("_iedBrowserAutoReadPages.Count >= 128", source); Assert.Contains("Tag = \"FocusedAuto\"", source); Assert.Contains("const int maxFocusedTargets = 12", source); Assert.Contains("cts.CancelAfter(TimeSpan.FromSeconds(8))", source); From 6417d594734364ed5d5d5c186e5f0b759b84c24b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:18:37 +0700 Subject: [PATCH 011/184] fix(datamodel): hydrate direct DO/DA navigation and disclosure-arrow selection --- MainWindow.IedBrowser.cs | 16 ++++-- Models/IedBrowserReadPlan.cs | 2 +- .../M217MFocusedAdmissionBehaviorTests.cs | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index bc91423a3..90cf45a2c 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -333,7 +333,7 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) !_iedBrowserAutoReadAdmission.TryStart(device.DeviceId, device.LiveValues.Association, automaticKey, currentPage!.Kind == "SGCB" ? 3 : - currentPage.Kind == "LN" ? 2 : 1)) + currentPage.Kind is "LN" or "DO" ? 2 : 1)) return; // A cached-model connection can replace the catalog. Bind only after @@ -1603,6 +1603,15 @@ private void IedBrowserDetailToggle_Click(object sender, RoutedEventArgs e) { if (sender is not Button { Tag: IedBrowserDetailRow detail } || !detail.HasChildren) return; + // Expand-arrow navigation must acquire the same exact DO as row click. + if (!string.IsNullOrEmpty(detail.ReadObjectReference) && + _iedBrowserNavigation.Current?.Payload is LiveIedLogicalNodeModel && + _iedBrowserNavigation.Current is { } lnPage) + { + _iedBrowserSelectedDoReference = detail.ReadObjectReference; + RefreshIedBrowserReadButtons(); + QueueIedBrowserFocusedRead(lnPage, detail.ReadObjectReference); + } ToggleIedBrowserDetails(detail); e.Handled = true; } @@ -1697,13 +1706,14 @@ private void QueueIedBrowserFocusedRead(IedBrowserTreeRow row, { if (SelectedDevice is not { IsConnected: true, IsDemo: false } device || device.LiveDiscoveryModel is null || - row.Kind is not ("SGCB" or "LN" or "DataSet") || + row.Kind is not ("SGCB" or "LN" or "DO" or "DA" or "DataSet") || row.Kind == "LN" && !row.Key.StartsWith("model/ln/", StringComparison.Ordinal)) return; var association = device.LiveValues.Association; var selectedDo = row.Kind == "LN" ? selectedDoReference : null; var key = IedBrowserFocusedAdmission.PageKey(row.Key, selectedDo); - var maxAttempts = row.Kind == "SGCB" ? 3 : row.Kind == "LN" ? 2 : 1; + var maxAttempts = row.Kind == "SGCB" ? 3 : + row.Kind is "LN" or "DO" ? 2 : 1; if (!_iedBrowserAutoReadAdmission.CanStart( device.DeviceId, association, key, maxAttempts)) return; diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index ceeefab70..1547d453a 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -129,7 +129,7 @@ public static IReadOnlyList BuildFocused( { if (liveModel is null || page is null || ledger.IsOffline || budget <= 0) return []; - if (page.Kind is not ("LN" or "SGCB" or "DataSet")) + if (page.Kind is not ("LN" or "DO" or "DA" or "SGCB" or "DataSet")) return []; // A DataSet is exactly its declared FCDA inventory; CF engineering // metadata is a Data Model attribute, NOT an invented DataSet member. diff --git a/tests/ARSAS.Tests/M217MFocusedAdmissionBehaviorTests.cs b/tests/ARSAS.Tests/M217MFocusedAdmissionBehaviorTests.cs index 4fe7340f0..2210a2b1f 100644 --- a/tests/ARSAS.Tests/M217MFocusedAdmissionBehaviorTests.cs +++ b/tests/ARSAS.Tests/M217MFocusedAdmissionBehaviorTests.cs @@ -93,4 +93,54 @@ public void ReportObservedDataModelLeafSuppressesOnlyItsExactReadGap() ledger.BeginAssociation(); Assert.False(ledger.TryGet(owner + ".units.SIUnit", "CF", out _)); } + [Fact] + public void DataModelDoAndDaPagesCanAutoReadExactVerifiedLeafAndCf() + { + const string owner = "GE650/MMXU1.Hz"; + LiveIedDataAttributeModel Attr(string relative, string fc) => new() + { + ObjectReference = owner, AttributePath = "Hz." + relative, + FunctionalConstraint = fc, + MmsReference = "GE650/MMXU1$" + fc + "$Hz$" + + relative.Replace('.', '$'), + SclBType = "Float32" + }; + var magnitude = Attr("mag.f", "MX"); + var si = Attr("units.SIUnit", "CF"); + var obj = new LiveIedDataObjectModel + { + Name = "Hz", Reference = owner, + Attributes = [magnitude, si, Attr("units.multiplier", "CF")] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "GE650", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", DataObjects = [obj] + }] + }] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var doPage = new IedBrowserTreeRow("model/do/" + owner, + "Hz", "DO", 3, obj); + var all = IedBrowserReadPlan.BuildFocused(model, doPage, ledger); + Assert.Equal(3, all.Count); + Assert.Contains(all, t => t.FunctionalConstraint == "CF" && + t.Reference.EndsWith("$units$multiplier", StringComparison.Ordinal)); + var daPage = new IedBrowserTreeRow("model/da/" + si.MmsReference, + "SIUnit", "DA", 4, si); + var selected = IedBrowserReadPlan.BuildFocused(model, daPage, ledger); + Assert.Single(selected); + Assert.Equal(si.MmsReference, selected[0].Reference); + Assert.True(ledger.PublishForAssociation(association, si.MmsReference, + "CF", "33", "", "", "MMS Read")); + Assert.Empty(IedBrowserReadPlan.BuildFocused(model, daPage, ledger)); + Assert.Equal(2, IedBrowserReadPlan.BuildFocused( + model, doPage, ledger).Count); + } + } From bb5daacfc30479853978d4434bfa41493c9fe6e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:24:37 +0700 Subject: [PATCH 012/184] test(ssot): replace obsolete DataSet CF and pre-dispatch visited assertions --- .../M217GDataSetSclSchemaFallbackTests.cs | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs index d802af0fa..04ba8434f 100644 --- a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs +++ b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs @@ -57,7 +57,7 @@ private static LiveIedDataSetMemberModel Member(string fc = "MX", => new() { Reference = reference, FunctionalConstraint = fc }; [Fact] - public void MeasurementMemberShowsOnlyExactDeclaredCfUnitAndMultiplierCompanions() + public void DataSetKeepsExactMxMembersWhileDataModelOwnsCfUnitAndMultiplier() { var live = Model(Obj(Attr("MX", "phsA.cVal.mag.f"), Attr("MX", "phsA.q"), Attr("CF", "phsA.units.SIUnit"), @@ -76,19 +76,30 @@ static IEnumerable Flatten( } } var all = Flatten(children).ToArray(); - Assert.Contains(all, row => - row.FunctionalConstraint == "CF" && - IedBrowserLiveValues.CanonicalReference(row.ValueReference) == - Owner + ".phsA.units.SIUnit"); - Assert.Contains(all, row => - row.FunctionalConstraint == "CF" && - IedBrowserLiveValues.CanonicalReference(row.ValueReference) == - Owner + ".phsA.units.multiplier"); + // A DataSet MX FCDA does not contain SIUnit/multiplier CF leaves. + Assert.All(all.Where(x => x.Kind == "Data"), row => + Assert.Equal("MX", row.FunctionalConstraint)); Assert.DoesNotContain(all, row => row.ValueReference.Contains(".phsB.units", StringComparison.Ordinal)); - // This only exposes exact IEC metadata. A declaration alone must not - // fabricate a fresh value; only the association-bound ledger may do so. + // The same verified live model exposes the CF leaves only through + // its Data Model DO/DA page. It is read-only, never an SCL value. + var objectModel = Assert.Single( + Assert.Single(Assert.Single(live.LogicalDevices).LogicalNodes).DataObjects); + var doPage = new IedBrowserTreeRow("model/do/" + Owner, + "PhV", "DO", 4, objectModel); var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var modelPlan = IedBrowserReadPlan.BuildFocused(live, doPage, ledger); + Assert.Contains(modelPlan, target => + target.FunctionalConstraint == "CF" && + IedBrowserLiveValues.CanonicalReference(target.Reference) == + Owner + ".phsA.units.SIUnit"); + Assert.Contains(modelPlan, target => + target.FunctionalConstraint == "CF" && + IedBrowserLiveValues.CanonicalReference(target.Reference) == + Owner + ".phsA.units.multiplier"); + // A declaration cannot fabricate a fresh value: only the SSOT wire + // channel may publish. Extra, partial configuration never becomes a unit. Assert.False(ledger.TryGet(Owner + ".phsA.units.SIUnit", "CF", out _)); var association = ledger.BeginAssociation(); Assert.True(ledger.PublishForAssociation(association, @@ -108,11 +119,12 @@ public void BrowserFocusedHydrationIsBoundedAndReusesExistingReadSafetyPath() var source = File.ReadAllText(Path.Combine(folder!.FullName, "MainWindow.IedBrowser.cs")); Assert.Contains("QueueIedBrowserFocusedRead(row)", source); - Assert.Contains("_iedBrowserAutoReadVisited.Add(row.Key)", source); + Assert.Contains("_iedBrowserAutoReadAdmission.TryStart(", source); + Assert.Contains("QueueIedBrowserFocusedRead(lnPage, detail.ReadObjectReference)", source); Assert.Contains("IedBrowserReadPlan.BuildFocused(", source); var planner = File.ReadAllText(Path.Combine(folder.FullName, "Models", "IedBrowserReadPlan.cs")); - Assert.Contains(".Take(Math.Min(budget, 24))", planner); + Assert.Contains(".Take(Math.Min(budget, page.Kind == \"SGCB\" ? 32 : 24))", planner); Assert.Contains("ledger.IsOffline", planner); Assert.Contains("FocusPriority(page.Kind, target)", planner); Assert.Contains("cts.CancelAfter(TimeSpan.FromSeconds(15))", source); From c8157644c41690cdce6240b9e53bd0438e4176e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:26:55 +0700 Subject: [PATCH 013/184] fix(ssot): project canonical MMS Read into monitoring Explorer signals without SOE state mutation --- MainWindow.IedBrowser.cs | 7 +-- Models/IedBrowserReadBindingIndex.cs | 11 ++++- .../CanonicalLiveValueSsotM213BTests.cs | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 90cf45a2c..ef38e6b24 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -400,9 +400,10 @@ void DrainReadResults() if (!accepted) return; _iedBrowserReadFailureEvidence?.ClearFailure(result.Target); successes++; - // Never overwrite a report-owned point during live acquisition. - if (!sourceDevice.IsMonitoring) - bindingIndex.Apply(result, sourceDevice.LiveValues, readAssociation); + // All non-control Device Explorer signals can reflect the + // same ledger even during monitoring if Report has not arrived. + // Runtime/SOE point rows remain exclusively runtime-owned. + bindingIndex.Apply(result, sourceDevice.LiveValues, readAssociation); changed = true; }); if (changed && ReferenceEquals(SelectedDevice, sourceDevice)) diff --git a/Models/IedBrowserReadBindingIndex.cs b/Models/IedBrowserReadBindingIndex.cs index d86affe16..5f2208dba 100644 --- a/Models/IedBrowserReadBindingIndex.cs +++ b/Models/IedBrowserReadBindingIndex.cs @@ -75,12 +75,12 @@ public int Apply(IedBrowserReadResult read, IedBrowserLiveValues live, { ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(live); - if (!IsCurrent || _device.IsMonitoring || !_device.IsConnected || + if (!IsCurrent || !_device.IsConnected || !string.IsNullOrWhiteSpace(read.Error) || string.IsNullOrWhiteSpace(read.Value) || live.Association != association || !live.TryGet(read.Target.Reference, read.Target.FunctionalConstraint, - out var observed, preferReport: false) || + out var observed, preferReport: _device.IsMonitoring) || observed.IsStale || observed.Association != association || !string.Equals(observed.Source, "MMS Read", StringComparison.Ordinal)) return 0; @@ -90,9 +90,16 @@ public int Apply(IedBrowserReadResult read, IedBrowserLiveValues live, if (_signals.TryGetValue(key, out var signals)) foreach (var signal in signals) { + if (signal.IsControlSignal) continue; ApplyObservedReadSignal(signal, observed, association); updated++; } + // Monitoring owns point state transitions and SOE: never write a + // background Read into those mutable runtime/command-feedback rows. + // Device Explorer's non-control signal projection can still display + // the exact canonical Read when NO Report exists for that FC key. + if (_device.IsMonitoring) + return updated; if (_points.TryGetValue(key, out var points)) foreach (var point in points) { diff --git a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213BTests.cs b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213BTests.cs index 22ccd2bf4..149480c47 100644 --- a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213BTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213BTests.cs @@ -115,4 +115,50 @@ static string Source(string file) Assert.Contains("_canonicalSignalProjections.Remove(deviceId)",monitor); Assert.Contains("if (snapshot.IsReportTraffic &&",monitor); } + [Fact] + public void FocusedMmsReadDuringMonitoringFillsExplorerOnlyWhenReportAbsent() + { + var device = new Iec61850MonitorDevice + { + IsConnected = true, IsMonitoring = true + }; + var signal = new SignalDefinition + { + ObjectReference = "GE650/MMXU1.Hz.mag.f", + FunctionalConstraint = "MX", + DataType = "FLOAT32", Unit = "Hz" + }; + var point = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, IecReference = signal.ObjectReference, + FunctionalConstraint = "MX", IecDataType = "FLOAT32" + }; + device.Signals.Add(signal); + device.Points.Add(point); + var index = new IedBrowserReadBindingIndex(device); + var association = device.LiveValues.Association; + var target = new IedBrowserReadTarget(signal.ObjectReference, "MX", "FLOAT32"); + var callback = new IedBrowserReadResult(target, "999", "", "", ""); + Assert.True(device.LiveValues.PublishForAssociation(association, + target.Reference, "MX", "49", "", "", "MMS Read")); + Assert.Equal(1, index.Apply(callback, device.LiveValues, association)); + Assert.Equal("49", signal.Value); + Assert.Equal("-", point.Value); // no manufactured point event or SOE + + Assert.True(device.LiveValues.PublishForAssociation(association, + target.Reference, "MX", "50", "Good", "", "Report")); + Assert.Equal(1, index.ProjectReportedSignals(device.LiveValues, + association, target.Reference, "MX")); + Assert.Equal("50 Hz", signal.Value); + Assert.True(device.LiveValues.PublishForAssociation(association, + target.Reference, "MX", "60", "", "", "MMS Read")); + Assert.Equal(0, index.Apply(callback, device.LiveValues, association)); + Assert.Equal("50 Hz", signal.Value); // Report remains authoritative + Assert.True(device.LiveValues.TryGet(target.Reference, "MX", out var observed)); + Assert.Equal("Report", observed.Source); + device.IsConnected = false; + device.IsConnected = true; + Assert.Equal(0, index.Apply(callback, device.LiveValues, association)); + } + } From 1bf016057115955a83ece30d97a1729519409848 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:31:44 +0700 Subject: [PATCH 014/184] test(ssot): assert FC only on typed DataSet leaves, not structure rows --- tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs index 04ba8434f..93b2232f7 100644 --- a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs +++ b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs @@ -77,7 +77,8 @@ static IEnumerable Flatten( } var all = Flatten(children).ToArray(); // A DataSet MX FCDA does not contain SIUnit/multiplier CF leaves. - Assert.All(all.Where(x => x.Kind == "Data"), row => + Assert.All(all.Where(x => x.Kind == "Data" && + !string.IsNullOrEmpty(x.FunctionalConstraint)), row => Assert.Equal("MX", row.FunctionalConstraint)); Assert.DoesNotContain(all, row => row.ValueReference.Contains(".phsB.units", StringComparison.Ordinal)); From df6acd91edda972f3b2ebf27a7868338fb046762 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:53:43 +0700 Subject: [PATCH 015/184] fix(ux-ssot): show canonical NamPlt DC summary and release orphaned Explorer dock row --- MainWindow.IedBrowser.cs | 11 ++ MainWindow.PersistentWorkbench.cs | 4 + MainWindow.xaml.cs | 10 +- Models/ExplorerCommandDockLayoutPolicy.cs | 17 +++ Models/IedBrowserNameplatePresentation.cs | 88 ++++++++++++++ Models/IedBrowserPresentation.cs | 4 + Models/IedBrowserStructuredDetails.cs | 1 + .../M217NNameplateAndExplorerGeometryTests.cs | 111 ++++++++++++++++++ 8 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 Models/ExplorerCommandDockLayoutPolicy.cs create mode 100644 Models/IedBrowserNameplatePresentation.cs create mode 100644 tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index ef38e6b24..9fe4f1cf3 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -675,6 +675,17 @@ private void RefreshIedBrowserVisibleValues() foreach (var row in _iedBrowserDetails.Rows) { if (string.IsNullOrWhiteSpace(row.ValueReference)) continue; + // An IEC NamPlt DO has DC children, not a fictional scalar under + // NamPlt [DC]. Show the exact vendor/d/swRev leaf already observed + // in the canonical association (or clearly labelled discovery). + if (IedBrowserNameplatePresentation.TryProject(row, device.LiveValues, + cfEvidence, out var nameplate)) + { + row.Value = nameplate.Value; + row.ObservationSource = nameplate.Source; + row.ObservationTooltip = nameplate.Provenance; + continue; + } if (cfEvidence.TryFormatUnitsParent( row.ValueReference, row.FunctionalConstraint, out var verifiedUnit, out var verifiedProvenance, diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs index 0f95df6bc..5e846a406 100644 --- a/MainWindow.PersistentWorkbench.cs +++ b/MainWindow.PersistentWorkbench.cs @@ -134,6 +134,10 @@ explorerTab.Content is not Grid legacyExplorerLayout || // Remove before adding: WPF elements can have only one logical/visual parent. legacyExplorerLayout.Children.Remove(explorerPane); legacyCommandParent.Children.Remove(CommandPanelExpander); + // The legacy tab still has a named Grid.RowDefinition after the + // expander moves. Release any previous Star height immediately, even + // if the selected device has many controls. + ExplorerCommandDockRow.Height = GridLength.Auto; root.Children.Remove(MainTabs); // The old Explorer tab now owns only its right-hand data workspace. diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 685fe73e3..e4799e68b 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1423,9 +1423,13 @@ private void CommandPanel_Collapsed(object sender, RoutedEventArgs e) private void ResizeCommandDockForVerifiedControls() { if (ExplorerCommandDockRow is null) return; - ExplorerCommandDockRow.Height = CommandPanelExpander?.IsExpanded == true && - (SelectedDevice?.CommandSignals.Count ?? 0) > 5 - ? new GridLength(1, GridUnitType.Star) : GridLength.Auto; + // The Command Dock is reparented into the shared shell by P0. + // Its previous Explorer tab row is now EMPTY. Giving that row Star + // height for >5 controls silently steals half the Live Values viewport. + ExplorerCommandDockRow.Height = ExplorerCommandDockLayoutPolicy.LegacyRowHeight( + dockReparented: _persistentWorkbench is not null, + commandExpanded: CommandPanelExpander?.IsExpanded == true, + controlCount: SelectedDevice?.CommandSignals.Count ?? 0); } private async void RefreshCommandValues_Click(object sender, RoutedEventArgs e) diff --git a/Models/ExplorerCommandDockLayoutPolicy.cs b/Models/ExplorerCommandDockLayoutPolicy.cs new file mode 100644 index 000000000..1e116a8b4 --- /dev/null +++ b/Models/ExplorerCommandDockLayoutPolicy.cs @@ -0,0 +1,17 @@ +using System.Windows; + +namespace ArIED61850Tester.Models; + +/// +/// Pure WPF geometry policy. When the operator Command Dock is in the +/// persistent shared shell, the old Explorer tab row has no content and must +/// NEVER consume star height, irrespective of control count or expansion. +/// +public static class ExplorerCommandDockLayoutPolicy +{ + public static GridLength LegacyRowHeight( + bool dockReparented, bool commandExpanded, int controlCount) + => dockReparented || !commandExpanded || controlCount <= 5 + ? GridLength.Auto + : new GridLength(1, GridUnitType.Star); +} diff --git a/Models/IedBrowserNameplatePresentation.cs b/Models/IedBrowserNameplatePresentation.cs new file mode 100644 index 000000000..f43ebb089 --- /dev/null +++ b/Models/IedBrowserNameplatePresentation.cs @@ -0,0 +1,88 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Renders an IEC LN NamPlt Data Object from the SAME association-scoped +/// FC=DC observations as its expanded children. The parent is not an MMS +/// scalar: only exact model-declared, unique child leaf references are used. +/// No SCL declared text is promoted to a live value. +/// +public static class IedBrowserNameplatePresentation +{ + public sealed record Projection(string Value, string Source, string Provenance); + + private static readonly string[] PreferredLeaves = + ["vendor", "d", "swRev", "configRev"]; + + public static IReadOnlyList BuildReferences(LiveIedDataObjectModel obj) + { + ArgumentNullException.ThrowIfNull(obj); + if (!string.Equals(obj.Name, "NamPlt", StringComparison.Ordinal)) + return []; + var owner = IedBrowserLiveValues.CanonicalReference(obj.Reference); + // Never turn a prefix or different FC into a Nameplate association. + if (owner.Length == 0) return []; + var result = new List(4); + foreach (var leaf in PreferredLeaves) + { + var reference = owner + "." + leaf; + var exact = obj.Attributes.Where(attr => + string.Equals(attr.FunctionalConstraint, "DC", StringComparison.OrdinalIgnoreCase) && + string.Equals(IedBrowserLiveValues.CanonicalReference(attr.MmsReference), + reference, StringComparison.Ordinal)).Take(2).ToArray(); + if (exact.Length == 1) + result.Add(reference); + } + return result; + } + + public static bool TryProject(IedBrowserDetailRow row, + IedBrowserLiveValues ledger, IedBrowserCfEvidencePresentation engineering, + out Projection projection) + { + ArgumentNullException.ThrowIfNull(row); + ArgumentNullException.ThrowIfNull(ledger); + ArgumentNullException.ThrowIfNull(engineering); + projection = null!; + if (!row.IsDataObject || row.NameplateDcReferences.Count == 0 || + ledger.IsOffline) + { + // Offline still projects a stale historical observation from the + // canonical ledger, but it must never look live. + if (!row.IsDataObject || row.NameplateDcReferences.Count == 0) + return false; + } + + foreach (var reference in row.NameplateDcReferences) + { + // Same exact FC and association; never read sibling or guess vendor. + if (ledger.TryGet(reference, "DC", out var observed)) + { + if (observed.Association != ledger.Association || + string.IsNullOrWhiteSpace(observed.Value)) + continue; + projection = new Projection( + observed.IsStale ? "Offline · " + observed.Value : observed.Value, + observed.IsStale ? "Stale " + observed.Source : observed.Source, + $"{reference} [DC] · {observed.Source} · observed " + + $"{observed.ObservedUtc:yyyy-MM-dd HH:mm:ss} UTC · association {observed.Association}"); + return true; + } + } + // A verified canonical discovery-instance value is an explicitly + // HISTORICAL read, not a fresh MMS observation or an SCL declaration. + foreach (var reference in row.NameplateDcReferences) + { + if (!engineering.TryGet(reference, "DC", out var value, + out var evidence, out var isLive) || + string.IsNullOrWhiteSpace(value)) + continue; + projection = new Projection(value, + isLive ? "MMS Read" : "Discovery snapshot", + reference + " [DC] · " + evidence); + return true; + } + return false; + } +} diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index c85bd7b1f..d3cbbe3da 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -223,6 +223,10 @@ public string ObservationTooltip // These fields are independent, not a synthetic IEC scalar or phasor. public IReadOnlyList ScalarComponents { get; init; } = Array.Empty(); + // Model-proven, immutable metadata ONLY: priority-ordered exact FC=DC + // leaves of this Nameplate object. No values are retained outside SSOT. + public IReadOnlyList NameplateDcReferences { get; init; } = + Array.Empty(); // A typed child of a received q/t field; never treat the entire parent // value text as this individual Boolean/time-quality bit. public string EvidenceField { get; init; } = ""; diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index e45098fa0..e1220e335 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -111,6 +111,7 @@ public static IedBrowserDetailRow DataObject( IsDataObject = true, ReadObjectReference = obj.Reference, ScalarComponents = scalarComponents, + NameplateDcReferences = IedBrowserNameplatePresentation.BuildReferences(obj), PolarPlan = polarPlan, PhasePlans = phasePlans, ValueReference = hasLeaf ? processLeaf : obj.Reference, diff --git a/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs b/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs new file mode 100644 index 000000000..210d93b5f --- /dev/null +++ b/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs @@ -0,0 +1,111 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217NNameplateAndExplorerGeometryTests +{ + private const string Owner = "BCUGEF650/MMXU1.NamPlt"; + + private static LiveIedDataAttributeModel Attr(string leaf, string fc = "DC", + string? owner = null) => new() + { + ObjectReference = owner ?? Owner, + AttributePath = "NamPlt." + leaf, + FunctionalConstraint = fc, + MmsReference = (owner ?? Owner).Replace(".NamPlt", "") + + "$" + fc + "$NamPlt$" + leaf, + SclBType = "VisString255" + }; + + private static LiveIedDataObjectModel Nameplate(params LiveIedDataAttributeModel[] attrs) + => new() { Name = "NamPlt", Reference = Owner, Attributes = attrs }; + + [Fact] + public void ExpandedObservedDcVendorAlsoPopulatesNameplateDoWithoutAnotherRead() + { + var obj = Nameplate(Attr("vendor"), Attr("swRev"), Attr("d"), + Attr("vendor", "ST"), + Attr("vendor", owner: "BCUGEF650/MMXU2.NamPlt")); + var row = IedBrowserStructuredDetails.DataObject(obj, uniqueProcessFc: null); + Assert.Equal("NamPlt", row.Name); + Assert.Equal(3, row.NameplateDcReferences.Count); + Assert.Contains(Owner + ".vendor", row.NameplateDcReferences); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(association, + Owner + ".vendor", "DC", "GE Multilin", "", "", "MMS Read")); + Assert.True(ledger.PublishForAssociation(association, + Owner + ".swRev", "DC", "6.20", "", "", "MMS Read")); + var evidence = new IedBrowserCfEvidencePresentation(ledger, null, null); + Assert.True(IedBrowserNameplatePresentation.TryProject(row, + ledger, evidence, out var presentation)); + Assert.Equal("GE Multilin", presentation.Value); + Assert.Equal("MMS Read", presentation.Source); + Assert.Contains(".vendor [DC]", presentation.Provenance); + // Nameplate summary is a projection. No invented parent scalar. + Assert.False(ledger.TryGet(Owner, "DC", out _)); + Assert.False(ledger.TryGet(Owner + ".vendor", "ST", out _)); + } + + [Fact] + public void NameplateUsesOnlyExactDcLeavesAndReconnectNeverShowsOldLiveValue() + { + var row = IedBrowserStructuredDetails.DataObject(Nameplate( + Attr("vendor"), Attr("d"), Attr("swRev")), + uniqueProcessFc: null); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(association, + Owner + ".vendor", "ST", "BAD", "", "", "Report")); + Assert.True(ledger.PublishForAssociation(association, + Owner + ".d", "DC", "Measurement", "", "", "MMS Read")); + var evidence = new IedBrowserCfEvidencePresentation(ledger, null, null); + Assert.True(IedBrowserNameplatePresentation.TryProject(row, ledger, evidence, + out var result)); + Assert.Equal("Measurement", result.Value); + ledger.BeginAssociation(); + Assert.False(IedBrowserNameplatePresentation.TryProject(row, + ledger, evidence, out _)); + } + + [Fact] + public void EmptyLegacyDockRowCannotStealExplorerHeightAfterReparent() + { + foreach (var controls in new[] { 0, 5, 6, 16, 150 }) + { + var detached = ExplorerCommandDockLayoutPolicy.LegacyRowHeight( + dockReparented: true, commandExpanded: true, controlCount: controls); + Assert.True(detached.IsAuto); + } + Assert.True(ExplorerCommandDockLayoutPolicy.LegacyRowHeight( + dockReparented: false, commandExpanded: false, controlCount: 16).IsAuto); + Assert.True(ExplorerCommandDockLayoutPolicy.LegacyRowHeight( + dockReparented: false, commandExpanded: true, controlCount: 5).IsAuto); + Assert.True(ExplorerCommandDockLayoutPolicy.LegacyRowHeight( + dockReparented: false, commandExpanded: true, controlCount: 6).IsStar); + } + + [Fact] + public void RuntimeDockTransferResetsEmptyLegacyRowAndKeepsExplorerGridStar() + { + static string Source(string relative) + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root != null) + { + var filename = Path.Combine(root.FullName, relative); + if (File.Exists(filename)) return File.ReadAllText(filename); + root = root.Parent; + } + throw new FileNotFoundException(relative); + } + var transfer = Source("MainWindow.PersistentWorkbench.cs"); + Assert.Contains("ExplorerCommandDockRow.Height = GridLength.Auto;", transfer); + var ui = Source("MainWindow.xaml.cs"); + Assert.Contains("dockReparented: _persistentWorkbench is not null", ui); + var xaml = Source("MainWindow.xaml"); + Assert.Contains("", xaml); + Assert.Contains("Grid.Row=\"2\" ItemsSource=\"{Binding SelectedDevice.Points}\"", xaml); + } +} From 27caf8b17df38c7abd181534d16274c021b25e4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:57:05 +0700 Subject: [PATCH 016/184] fix(nameplate): preserve explicit stale provenance across offline association retirement --- Models/IedBrowserNameplatePresentation.cs | 15 ++++++--------- .../M217NNameplateAndExplorerGeometryTests.cs | 5 +++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Models/IedBrowserNameplatePresentation.cs b/Models/IedBrowserNameplatePresentation.cs index f43ebb089..3cbd4c7f6 100644 --- a/Models/IedBrowserNameplatePresentation.cs +++ b/Models/IedBrowserNameplatePresentation.cs @@ -45,21 +45,18 @@ public static bool TryProject(IedBrowserDetailRow row, ArgumentNullException.ThrowIfNull(ledger); ArgumentNullException.ThrowIfNull(engineering); projection = null!; - if (!row.IsDataObject || row.NameplateDcReferences.Count == 0 || - ledger.IsOffline) - { - // Offline still projects a stale historical observation from the - // canonical ledger, but it must never look live. - if (!row.IsDataObject || row.NameplateDcReferences.Count == 0) - return false; - } + if (!row.IsDataObject || row.NameplateDcReferences.Count == 0) + return false; foreach (var reference in row.NameplateDcReferences) { // Same exact FC and association; never read sibling or guess vendor. if (ledger.TryGet(reference, "DC", out var observed)) { - if (observed.Association != ledger.Association || + // MarkOffline retires the association but preserves the + // last observation; render it visibly stale. A new connected + // association always starts with a cleared canonical ledger. + if (!observed.IsStale && observed.Association != ledger.Association || string.IsNullOrWhiteSpace(observed.Value)) continue; projection = new Projection( diff --git a/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs b/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs index 210d93b5f..ac96cbd8a 100644 --- a/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs +++ b/tests/ARSAS.Tests/M217NNameplateAndExplorerGeometryTests.cs @@ -64,6 +64,11 @@ public void NameplateUsesOnlyExactDcLeavesAndReconnectNeverShowsOldLiveValue() Assert.True(IedBrowserNameplatePresentation.TryProject(row, ledger, evidence, out var result)); Assert.Equal("Measurement", result.Value); + ledger.MarkOffline(); + Assert.True(IedBrowserNameplatePresentation.TryProject( + row, ledger, evidence, out var offline)); + Assert.Equal("Offline · Measurement", offline.Value); + Assert.StartsWith("Stale ", offline.Source); ledger.BeginAssociation(); Assert.False(IedBrowserNameplatePresentation.TryProject(row, ledger, evidence, out _)); From 4ec55377ef3708ed8daeb3dc1bffa7c271388046 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:01:57 +0700 Subject: [PATCH 017/184] test(ux): replace obsolete Command Dock star-row regression contract with detached-shell invariant --- .../P109QCommandDockCommissioningRegressionTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs index 93d4fb2db..5d4a9c0a1 100644 --- a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs +++ b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs @@ -93,10 +93,15 @@ public void CommandDockHasConstrainedResponsiveViewportAndInspector() Assert.DoesNotContain("MaxHeight=\"260\"", xaml); Assert.Contains("Height=\"*\"/>", xaml); var main = Read("MainWindow.xaml.cs"); - Assert.Contains("new GridLength(1, GridUnitType.Star)", main); + // The legacy explorer row must not steal half the live-signal viewport + // once the operator dock has been moved to the persistent shell. Assert.Contains("ResizeCommandDockForVerifiedControls()", main); - Assert.Contains("(SelectedDevice?.CommandSignals.Count ?? 0) > 5", main); - Assert.Contains("? new GridLength(1, GridUnitType.Star) : GridLength.Auto", main); + Assert.Contains("ExplorerCommandDockLayoutPolicy.LegacyRowHeight(", main); + Assert.Contains("dockReparented: _persistentWorkbench is not null", main); + var transfer = Read("MainWindow.PersistentWorkbench.cs"); + Assert.Contains("ExplorerCommandDockRow.Height = GridLength.Auto;", transfer); + var policy = Read("Models/ExplorerCommandDockLayoutPolicy.cs"); + Assert.Contains("dockReparented || !commandExpanded || controlCount <= 5", policy); Assert.Contains("RowHeight=\"43\" MinRowHeight=\"42\"", xaml); Assert.Contains("Height=\"29\" MinHeight=\"29\"", xaml); var styles = Read("App.xaml"); From 633b03ad9bbc8f8a9686e8f38d3ccc20df1d4cd3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:24:13 +0700 Subject: [PATCH 018/184] fix(sg): decode IEDScout-style typed LN$SG MMS Read into exact canonical FC=SG leaves --- Models/IedBrowserSgRootProjection.cs | 89 +++++++++++++++++++ Services/Iec61850MonitorRuntime.cs | 10 +++ Services/IedBrowserIsolatedReadService.cs | 10 +++ Services/IedBrowserSgRootReadFlight.cs | 68 ++++++++++++++ .../NativeIec61850Client.SettingGroupRead.cs | 55 ++++++++++++ .../ARSAS.Tests/M217PStructuredSgReadTests.cs | 74 +++++++++++++++ 6 files changed, 306 insertions(+) create mode 100644 Models/IedBrowserSgRootProjection.cs create mode 100644 Services/IedBrowserSgRootReadFlight.cs create mode 100644 Services/NativeIec61850Client.SettingGroupRead.cs create mode 100644 tests/ARSAS.Tests/M217PStructuredSgReadTests.cs diff --git a/Models/IedBrowserSgRootProjection.cs b/Models/IedBrowserSgRootProjection.cs new file mode 100644 index 000000000..1b91014ea --- /dev/null +++ b/Models/IedBrowserSgRootProjection.cs @@ -0,0 +1,89 @@ +using AR.Iec61850.Mms; + +namespace ArIED61850Tester.Models; + +/// +/// Admission of IEC FC=SG scalars from one wire-read LN$SG STRUCTURE, +/// only with the ordered wire GetVariableAccessAttributes TypeSpecification. +/// There is no ordinal heuristic, SCL-value fallback, second cache or write. +/// +public static class IedBrowserSgRootProjection +{ + public static string RootFor(IedBrowserReadTarget target) + { + if (!string.Equals(target.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase)) + return ""; + var wire = target.Reference; + var cut = wire.IndexOf("$SG$", StringComparison.Ordinal); + if (cut > 0 && wire.Contains('/', StringComparison.Ordinal)) + return wire[..cut] + "$SG"; + // Canonical IEC dotted naming; FC comes ONLY from verified target. + var canonical = IedBrowserLiveValues.CanonicalReference(wire); + var slash = canonical.IndexOf('/'); + var dot = slash < 0 ? -1 : canonical.IndexOf('.', slash + 1); + return dot > slash + 1 ? canonical[..dot] + "$SG" : ""; + } + + public static IReadOnlyList Project( + string root, MmsTypeSpecificationNode? wireType, + MmsDataValue? wireData, IReadOnlyList expected) + { + if (wireType is null || wireData is null || + wireType.Children.Count == 0 || + wireData.Kind != MmsDataKind.Structure || + !string.Equals(wireType.MmsType, "structure", + StringComparison.OrdinalIgnoreCase)) + return []; + var targetIndex = expected + .Where(target => string.Equals(RootFor(target), root, StringComparison.Ordinal)) + .GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + + "|" + target.FunctionalConstraint, StringComparer.Ordinal) + .Where(group => group.Count() == 1) + .ToDictionary(group => group.Key, group => group.Single(), StringComparer.Ordinal); + var slash = root.IndexOf('/'); + var fc = root.LastIndexOf("$SG", StringComparison.Ordinal); + if (slash < 1 || fc <= slash) return []; + var prefix = root[..fc]; + var output = new List(); + var paths = new HashSet(StringComparer.Ordinal); + void Walk(MmsTypeSpecificationNode schema, MmsDataValue value, string path, int depth) + { + if (depth > 12) return; + if (value.Kind == MmsDataKind.Structure) + { + // Ordered MMS type components are the sole path authority. + if (schema.Children.Count == 0 || + schema.Children.Count != value.Children.Count || + schema.Children.Any(node => string.IsNullOrWhiteSpace(node.Name)) || + schema.Children.Select(node => node.Name).Distinct(StringComparer.Ordinal) + .Count() != schema.Children.Count) + return; + for (var i = 0; i < schema.Children.Count; i++) + { + var child = schema.Children[i]; + Walk(child, value.Children[i], + path.Length == 0 ? child.Name : path + "." + child.Name, + depth + 1); + } + return; + } + // IEC SG array values need indexed schema identity and are not + // flattened into imaginary scalar targets. + if (value.Kind is MmsDataKind.Array or MmsDataKind.Unknown || + schema.Children.Count != 0 || path.Length == 0) + return; + var canonical = prefix + "." + path; + var key = canonical + "|SG"; + if (!paths.Add(key) || !targetIndex.TryGetValue(key, out var target)) + return; + var display = MmsDataValueRenderer.ToCompactString(value); + if (!IedBrowserReadValuePolicy.TryAcceptDisplay( + display, out var accepted, out _)) + return; + output.Add(new IedBrowserReadResult(target, accepted, "", "", "")); + } + Walk(wireType, wireData, "", 0); + return output; + } +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 26c5d11f9..1fa2d65f2 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -149,6 +149,7 @@ private async Task ReadBrowserValuesAsync( if (device.IsMonitoring) throw new InvalidOperationException("Live monitor is active. Browser values follow its report stream; stop monitoring for an explicit MMS Read."); var succeeded = 0; + var sgRootFlight = new IedBrowserSgRootReadFlight(session.Client, targets); for (var index = 0; index < targets.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); @@ -158,6 +159,15 @@ private async Task ReadBrowserValuesAsync( { using var singleRead = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); singleRead.CancelAfter(TimeSpan.FromSeconds(5)); + var verifiedSg = await sgRootFlight.TryReadAsync( + target, singleRead.Token).ConfigureAwait(false); + if (verifiedSg is not null) + { + values.Report(verifiedSg); + succeeded++; + progress.Report(new IedBrowserReadProgress(index + 1, targets.Count)); + continue; + } var raw = await session.Client.ReadValueAsync( target.Reference, target.FunctionalConstraint, target.DataType, singleRead.Token).ConfigureAwait(false); diff --git a/Services/IedBrowserIsolatedReadService.cs b/Services/IedBrowserIsolatedReadService.cs index 3b1521c8a..12759b9a6 100644 --- a/Services/IedBrowserIsolatedReadService.cs +++ b/Services/IedBrowserIsolatedReadService.cs @@ -40,6 +40,7 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) } var successful = 0; + var sgRootFlight = new IedBrowserSgRootReadFlight(client, targets); for (var i = 0; i < targets.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -51,6 +52,15 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) { using var leafToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); leafToken.CancelAfter(TimeSpan.FromSeconds(5)); + var verifiedSg = await sgRootFlight.TryReadAsync( + target, leafToken.Token).ConfigureAwait(false); + if (verifiedSg is not null) + { + values.Report(verifiedSg); + successful++; + progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); + continue; + } var raw = await client.ReadValueAsync(target.Reference, target.FunctionalConstraint, target.DataType, leafToken.Token) .ConfigureAwait(false); diff --git a/Services/IedBrowserSgRootReadFlight.cs b/Services/IedBrowserSgRootReadFlight.cs new file mode 100644 index 000000000..51a076702 --- /dev/null +++ b/Services/IedBrowserSgRootReadFlight.cs @@ -0,0 +1,68 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// One in-flight read-plan local typed FC=SG root result projection. +/// This dictionary is discarded after a bounded Read; never a value cache. +/// +internal sealed class IedBrowserSgRootReadFlight +{ + private readonly NativeIec61850Client _client; + private readonly IReadOnlyList _targets; + private readonly Dictionary> + _completed = new(StringComparer.Ordinal); + private const int MaxRootReadsPerPage = 8; + + internal IedBrowserSgRootReadFlight(NativeIec61850Client client, + IReadOnlyList targets) + { + _client = client; + _targets = targets; + } + + internal async Task TryReadAsync( + IedBrowserReadTarget target, CancellationToken cancellationToken) + { + if (_targets.Count > 32 || !string.Equals(target.FunctionalConstraint, + "SG", StringComparison.OrdinalIgnoreCase)) + return null; + var root = IedBrowserSgRootProjection.RootFor(target); + if (root.Length == 0) return null; + if (!_completed.TryGetValue(root, out var values)) + { + if (_completed.Count >= MaxRootReadsPerPage) + return null; + var siblings = _targets.Where(t => + string.Equals(IedBrowserSgRootProjection.RootFor(t), + root, StringComparison.Ordinal)).ToArray(); + IReadOnlyList read = []; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + read = await _client.ReadVerifiedSgRootAsync( + root, siblings, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Existing individual leaf path remains an exact FC fallback. + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // No speculative values from a malformed/unavailable FC root. + System.Diagnostics.Debug.WriteLine("SG root read unavailable: " + ex.Message); + } + values = read.GroupBy(v => + IedBrowserLiveValues.CanonicalReference(v.Target.Reference) + + "|" + v.Target.FunctionalConstraint, StringComparer.Ordinal) + .Where(g => g.Count() == 1) + .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); + _completed.Add(root, values); + } + var key = IedBrowserLiveValues.CanonicalReference(target.Reference) + + "|" + target.FunctionalConstraint; + return values.TryGetValue(key, out var result) ? result : null; + } +} diff --git a/Services/NativeIec61850Client.SettingGroupRead.cs b/Services/NativeIec61850Client.SettingGroupRead.cs new file mode 100644 index 000000000..41e0b3edd --- /dev/null +++ b/Services/NativeIec61850Client.SettingGroupRead.cs @@ -0,0 +1,55 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + /// + /// IEDScout-compatible wire shape: one read-only MMS Read of a verified + /// LN$SG variable (not repeated speculative LN$SG$DO$setVal reads). + /// Wire GVA defines the actual ordered child names and types. A mismatched + /// structure fails closed instead of assigning a neighbor's setting. + /// + public async Task> ReadVerifiedSgRootAsync( + string root, IReadOnlyList targets, + CancellationToken cancellationToken) + { + if (!_session.IsMmsInitiated || targets.Count == 0 || + targets.Any(t => !string.Equals( + IedBrowserSgRootProjection.RootFor(t), root, StringComparison.Ordinal))) + return []; + var slash = root.IndexOf('/'); + if (slash <= 0 || slash >= root.Length - 1 || + !root.EndsWith("$SG", StringComparison.Ordinal)) + return []; + var reference = new MmsObjectReference( + root[..slash], root[(slash + 1)..], string.Empty); + // Two fixed bounded requests, both on the existing read-only client: + // type specification and exactly one structured MMS FC root. + var type = await RunMmsOperationAsync(() => + _session.GetVariableAccessAttributesAsync(reference, cancellationToken), + cancellationToken).ConfigureAwait(false); + if (!type.IsSuccess || type.TypeSpecification is null) + { + LastErrorMessage = "SG structured read cannot verify wire type for " + + root + ": " + type.Message; + return []; + } + var read = await RunMmsOperationAsync(() => + _session.ReadSingleVariableAsync(reference, cancellationToken), + cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + LastErrorMessage = "SG structured MMS Read failed for " + root + + ": " + read.Message; + return []; + } + var result = IedBrowserSgRootProjection.Project( + root, type.TypeSpecification, read.Value, targets); + if (result.Count == 0) + LastErrorMessage = "SG structure received but type/order did not prove " + + "any exact requested leaf in " + root + ". No value published."; + return result; + } +} diff --git a/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs b/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs new file mode 100644 index 000000000..62600d2cc --- /dev/null +++ b/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs @@ -0,0 +1,74 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217PStructuredSgReadTests +{ + const string Root = "BCUGEF650/SGgndPIOC1$SG"; + static IedBrowserReadTarget Target(string leaf, string fc = "SG") + => new("BCUGEF650/SGgndPIOC1$" + fc + "$" + + leaf.Replace('.', '$'), fc, "Int32"); + + static MmsTypeSpecificationNode Type(string name, string mms = "integer", + params MmsTypeSpecificationNode[] children) + => new() { Name = name, MmsType = mms, Children = children }; + + [Fact] + public void OneMmsLnFcStructureProjectsOnlyTypeVerifiedExactSgLeaves() + { + var type = Type("", "structure", + Type("InMagTyp", "structure", Type("setVal")), + Type("PIOCEna", "structure", Type("setVal")), + Type("StrVal", "structure", Type("setMag"))); + var wire = MmsDataValue.Structure([ + MmsDataValue.Structure([MmsDataValue.Integer(2)]), + MmsDataValue.Structure([MmsDataValue.Boolean(false)]), + MmsDataValue.Structure([MmsDataValue.Integer(0)])]); + var targets = new[] { Target("InMagTyp.setVal"), + Target("PIOCEna.setVal"), Target("StrVal.setMag"), + Target("InMagTyp.setVal", "SP"), + new IedBrowserReadTarget("OTHER/SGgndPIOC1$SG$PIOCEna$setVal", "SG", "Boolean") }; + var results = IedBrowserSgRootProjection.Project(Root, type, wire, targets); + Assert.Equal(3, results.Count); + Assert.Contains(results, v => v.Target.Reference.EndsWith( + "$InMagTyp$setVal", StringComparison.Ordinal) && v.Value == "2"); + Assert.Contains(results, v => v.Target.Reference.EndsWith( + "$PIOCEna$setVal", StringComparison.Ordinal) && v.Value == "false"); + Assert.DoesNotContain(results, v => v.Target.Reference.StartsWith("OTHER/", StringComparison.Ordinal)); + Assert.Equal(Root, IedBrowserSgRootProjection.RootFor(Target("PIOCEna.setVal"))); + Assert.Equal("", IedBrowserSgRootProjection.RootFor(Target("PIOCEna.setVal", "SP"))); + } + + [Fact] + public void StructureSchemaMismatchesAndAmbiguousDuplicatePathsFailClosed() + { + var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal"))); + var wire = MmsDataValue.Structure([MmsDataValue.Structure( + [MmsDataValue.Boolean(true), MmsDataValue.Integer(0)])]); + Assert.Empty(IedBrowserSgRootProjection.Project( + Root, type, wire, [Target("PIOCEna.setVal")])); + Assert.Empty(IedBrowserSgRootProjection.Project(Root, + Type("", "structure", Type("PIOCEna"), Type("PIOCEna")), + MmsDataValue.Structure([MmsDataValue.Integer(1), MmsDataValue.Integer(2)]), + [Target("PIOCEna.setVal")])); + Assert.Empty(IedBrowserSgRootProjection.Project(Root, type, + MmsDataValue.Structure([MmsDataValue.Structure([MmsDataValue.Boolean(true)])]), + [Target("PIOCEna.setVal"), Target("PIOCEna.setVal")])); + } + + [Fact] + public void SgProjectionIsOnlyEphemeralReadResultAndAssociationFenceStillRequired() + { + var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal"))); + var wire = MmsDataValue.Structure([MmsDataValue.Structure([MmsDataValue.Boolean(true)])]); + var result = Assert.Single(IedBrowserSgRootProjection.Project( + Root, type, wire, [Target("PIOCEna.setVal")])); + var ledger = new IedBrowserLiveValues(); + var before = ledger.BeginAssociation(); + ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(before, result.Target.Reference, "SG", + result.Value, "", "", "MMS Read")); + Assert.False(ledger.TryGet(result.Target.Reference, "SG", out _)); + } +} From b2495f1d41c66b847de5bf5c27fd4036531ff2c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:26:29 +0700 Subject: [PATCH 019/184] chore(sg): comply with clean-room source identifier guard --- Services/NativeIec61850Client.SettingGroupRead.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SettingGroupRead.cs b/Services/NativeIec61850Client.SettingGroupRead.cs index 41e0b3edd..7351fc9f4 100644 --- a/Services/NativeIec61850Client.SettingGroupRead.cs +++ b/Services/NativeIec61850Client.SettingGroupRead.cs @@ -6,7 +6,7 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { /// - /// IEDScout-compatible wire shape: one read-only MMS Read of a verified + /// Captured MMS-compatible wire shape: one read-only MMS Read of a verified /// LN$SG variable (not repeated speculative LN$SG$DO$setVal reads). /// Wire GVA defines the actual ordered child names and types. A mismatched /// structure fails closed instead of assigning a neighbor's setting. From 326484e703c92adc9a78bdc4d8273d4600323710 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:27:17 +0700 Subject: [PATCH 020/184] fix(sg): use supported exact-char root locator for wire identity --- Models/IedBrowserSgRootProjection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/IedBrowserSgRootProjection.cs b/Models/IedBrowserSgRootProjection.cs index 1b91014ea..8ae9948bf 100644 --- a/Models/IedBrowserSgRootProjection.cs +++ b/Models/IedBrowserSgRootProjection.cs @@ -16,7 +16,7 @@ public static string RootFor(IedBrowserReadTarget target) return ""; var wire = target.Reference; var cut = wire.IndexOf("$SG$", StringComparison.Ordinal); - if (cut > 0 && wire.Contains('/', StringComparison.Ordinal)) + if (cut > 0 && wire.Contains('/')) return wire[..cut] + "$SG"; // Canonical IEC dotted naming; FC comes ONLY from verified target. var canonical = IedBrowserLiveValues.CanonicalReference(wire); From 7116fa49f39a8f7c1c1d9cc44ca804d938ef2ef5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:34:05 +0700 Subject: [PATCH 021/184] fix(sg): require matching typed primitive when flattening exact MMS SG structure --- Models/IedBrowserSgRootProjection.cs | 19 ++++++++++++++++++- .../ARSAS.Tests/M217PStructuredSgReadTests.cs | 17 ++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Models/IedBrowserSgRootProjection.cs b/Models/IedBrowserSgRootProjection.cs index 8ae9948bf..d11215437 100644 --- a/Models/IedBrowserSgRootProjection.cs +++ b/Models/IedBrowserSgRootProjection.cs @@ -71,7 +71,8 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue value, string path, int // IEC SG array values need indexed schema identity and are not // flattened into imaginary scalar targets. if (value.Kind is MmsDataKind.Array or MmsDataKind.Unknown || - schema.Children.Count != 0 || path.Length == 0) + schema.Children.Count != 0 || path.Length == 0 || + !IsMatchingPrimitiveType(schema.MmsType, value.Kind)) return; var canonical = prefix + "." + path; var key = canonical + "|SG"; @@ -86,4 +87,20 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue value, string path, int Walk(wireType, wireData, "", 0); return output; } + + private static bool IsMatchingPrimitiveType(string mmsType, MmsDataKind kind) + => mmsType.Trim().ToLowerInvariant() switch + { + "boolean" => kind == MmsDataKind.Boolean, + "integer" => kind is MmsDataKind.Integer or MmsDataKind.Unsigned, + "unsigned" => kind is MmsDataKind.Unsigned or MmsDataKind.Integer, + "floating-point" => kind == MmsDataKind.FloatingPoint, + "visible-string" => kind is MmsDataKind.VisibleString or MmsDataKind.MmsString, + "mms-string" => kind is MmsDataKind.MmsString or MmsDataKind.VisibleString, + "bit-string" => kind == MmsDataKind.BitString, + "utc-time" => kind == MmsDataKind.UtcTime, + "octet-string" => kind == MmsDataKind.OctetString, + "binary-time" => kind == MmsDataKind.BinaryTime, + _ => false + }; } diff --git a/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs b/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs index 62600d2cc..4691e75e7 100644 --- a/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs +++ b/tests/ARSAS.Tests/M217PStructuredSgReadTests.cs @@ -19,7 +19,7 @@ public void OneMmsLnFcStructureProjectsOnlyTypeVerifiedExactSgLeaves() { var type = Type("", "structure", Type("InMagTyp", "structure", Type("setVal")), - Type("PIOCEna", "structure", Type("setVal")), + Type("PIOCEna", "structure", Type("setVal", "boolean")), Type("StrVal", "structure", Type("setMag"))); var wire = MmsDataValue.Structure([ MmsDataValue.Structure([MmsDataValue.Integer(2)]), @@ -43,7 +43,7 @@ public void OneMmsLnFcStructureProjectsOnlyTypeVerifiedExactSgLeaves() [Fact] public void StructureSchemaMismatchesAndAmbiguousDuplicatePathsFailClosed() { - var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal"))); + var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal", "boolean"))); var wire = MmsDataValue.Structure([MmsDataValue.Structure( [MmsDataValue.Boolean(true), MmsDataValue.Integer(0)])]); Assert.Empty(IedBrowserSgRootProjection.Project( @@ -57,10 +57,21 @@ public void StructureSchemaMismatchesAndAmbiguousDuplicatePathsFailClosed() [Target("PIOCEna.setVal"), Target("PIOCEna.setVal")])); } + [Fact] + public void MatchingPositionButWrongWireScalarTypeCannotPublish() + { + var schema = Type("", "structure", + Type("PIOCEna", "structure", Type("setVal", "integer"))); + var wire = MmsDataValue.Structure([ + MmsDataValue.Structure([MmsDataValue.Boolean(true)])]); + Assert.Empty(IedBrowserSgRootProjection.Project( + Root, schema, wire, [Target("PIOCEna.setVal")])); + } + [Fact] public void SgProjectionIsOnlyEphemeralReadResultAndAssociationFenceStillRequired() { - var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal"))); + var type = Type("", "structure", Type("PIOCEna", "structure", Type("setVal", "boolean"))); var wire = MmsDataValue.Structure([MmsDataValue.Structure([MmsDataValue.Boolean(true)])]); var result = Assert.Single(IedBrowserSgRootProjection.Project( Root, type, wire, [Target("PIOCEna.setVal")])); From ac8858d713f9221ed09b244609445969f9251902 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:57:16 +0700 Subject: [PATCH 022/184] fix(browser): canonical IEC status presentation, explicit angle glyph and full-height Explorer viewport --- MainWindow.IedBrowser.cs | 4 ++ MainWindow.PersistentWorkbench.cs | 14 ++++++ Models/IedBrowserEngineeringComposite.cs | 2 +- .../IedBrowserObservedVectorPresentation.cs | 2 +- .../IedBrowserStandardStatusPresentation.cs | 44 ++++++++++++++++ .../M217QStatusAndExplorerRegressionTests.cs | 50 +++++++++++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Models/IedBrowserStandardStatusPresentation.cs create mode 100644 tests/ARSAS.Tests/M217QStatusAndExplorerRegressionTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 9fe4f1cf3..b3b8df8ed 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -815,9 +815,13 @@ private void RefreshIedBrowserVisibleValues() // operator formatter only when the current model declares DPC. // No name-only inference and no mutation of raw report evidence. if (row.EvidenceField.Length == 0) + { display = IedBrowserDoublePointPresentation.Format( display, observedReference, row.ValueReference, row.FunctionalConstraint, _iedBrowserValueSchemaIndex); + display = IedBrowserStandardStatusPresentation.Format( + observedReference, row.FunctionalConstraint, display); + } if (row.IsEngineeringValue && row.EvidenceField.Length == 0) display = IedBrowserEngineeringValuePresentation.Format( display, IedBrowserEngineeringValuePresentation.ResolveUnit( diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs index 5e846a406..3a88395b7 100644 --- a/MainWindow.PersistentWorkbench.cs +++ b/MainWindow.PersistentWorkbench.cs @@ -153,6 +153,20 @@ explorerTab.Content is not Grid legacyExplorerLayout || shell.Children.Add(explorerPane); MainTabs.Margin = new Thickness(0); + // The tab's hidden ContentPresenter otherwise can measure the selected + // Explorer grid at its short intrinsic height and leave an unallocated + // blue gap above the bottom Command Dock. Tie the content minimum to + // the actual star-row height, never to the number of displayed signals. + MainTabs.VerticalAlignment = VerticalAlignment.Stretch; + MainTabs.VerticalContentAlignment = VerticalAlignment.Stretch; + legacyExplorerLayout.VerticalAlignment = VerticalAlignment.Stretch; + shell.SizeChanged += (_, _) => + { + var viewport = shell.RowDefinitions[0].ActualHeight; + if (double.IsFinite(viewport) && viewport > 0 && + Math.Abs(legacyExplorerLayout.MinHeight - viewport) > 0.5) + legacyExplorerLayout.MinHeight = viewport; + }; Grid.SetColumn(MainTabs, 2); Grid.SetRow(MainTabs, 0); Grid.SetRowSpan(MainTabs, 1); diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index 2adc0ddbb..fd9103049 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -179,7 +179,7 @@ public static bool TryCompose( // unless the actual report/read evidence passed CanPair. var independentUnit = IedBrowserEngineeringValuePresentation.ResolveVectorAngleUnit( unitIndex, plan.AngleReference, plan.FunctionalConstraint); - text += "; angle " + IedBrowserEngineeringValuePresentation.Format( + text += "; ∠ (independent) " + IedBrowserEngineeringValuePresentation.Format( independentAngle.Value, independentUnit); references.Add(plan.AngleReference + "[independent " + plan.FunctionalConstraint + "]"); diff --git a/Models/IedBrowserObservedVectorPresentation.cs b/Models/IedBrowserObservedVectorPresentation.cs index f1a3be602..2b4865c3f 100644 --- a/Models/IedBrowserObservedVectorPresentation.cs +++ b/Models/IedBrowserObservedVectorPresentation.cs @@ -70,7 +70,7 @@ public static bool TryFormat( { // The field is independently observed, not a phasor angle // pair. Keep the reason in provenance, not in Value cells. - text += (text.Length == 0 ? "" : "; ") + "angle " + rendered; + text += (text.Length == 0 ? "" : "; ") + "∠ (independent) " + rendered; unpaired.Add(part.Label + ": " + (hasMag ? "angle not paired with magnitude" : "magnitude not observed")); diff --git a/Models/IedBrowserStandardStatusPresentation.cs b/Models/IedBrowserStandardStatusPresentation.cs new file mode 100644 index 000000000..f561ce2a0 --- /dev/null +++ b/Models/IedBrowserStandardStatusPresentation.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Display-only IEC status decoding of an already observed exact FC=ST leaf. +/// Generic SCL synthesized labels such as value1 are NOT the normative IEC +/// Beh/Mod/Health symbols. Never touch raw canonical MMS/Report observations. +/// +public static class IedBrowserStandardStatusPresentation +{ + public static string Format(string reference, string fc, string raw) + { + if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(raw)) + return raw; + var path = IedBrowserLiveValues.CanonicalReference(reference); + var slash = path.IndexOf('/'); + if (slash < 1) return raw; + var parts = path[(slash + 1)..].Split('.'); + if (parts.Length != 3 || !string.Equals(parts[2], "stVal", StringComparison.Ordinal)) + return raw; + var obj = parts[1]; + if (obj is not ("Mod" or "Beh" or "Health" or "PhyHealth")) + return raw; + var input = raw.Trim(); + if (input.StartsWith("value", StringComparison.OrdinalIgnoreCase)) + input = input["value".Length..]; + if (!long.TryParse(input, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var ordinal)) + return raw; + if (!Iec61850StandardEnumRegistry.TryResolve( + "", obj, obj is "Health" or "PhyHealth" ? "ENS" : "ENC", + "stVal", out var definition)) + return raw; + var value = definition.Values.FirstOrDefault(v => v.Ord == ordinal); + return value is null ? raw : value.Symbol switch + { + "ok" => "Ok", + var text => text + }; + } +} diff --git a/tests/ARSAS.Tests/M217QStatusAndExplorerRegressionTests.cs b/tests/ARSAS.Tests/M217QStatusAndExplorerRegressionTests.cs new file mode 100644 index 000000000..21aff9e2f --- /dev/null +++ b/tests/ARSAS.Tests/M217QStatusAndExplorerRegressionTests.cs @@ -0,0 +1,50 @@ +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217QStatusAndExplorerRegressionTests +{ + [Theory] + [InlineData("BCUGEF650/CSWI1.Mod.stVal", "value1", "on")] + [InlineData("BCUGEF650/CSWI1.Beh.stVal", "1", "on")] + [InlineData("BCUGEF650/CSWI1.Health.stVal", "value1", "Ok")] + [InlineData("BCUGEF650/CSWI1.Mod.stVal", "value5", "off")] + [InlineData("BCUGEF650/CSWI1.Health.stVal", "value2", "warning")] + public void StandardEnumsRenderByExactObservedFcAndDo(string reference, string raw, string expected) + => Assert.Equal(expected, + IedBrowserStandardStatusPresentation.Format(reference, "ST", raw)); + + [Theory] + [InlineData("BCUGEF650/CSWI1.FltNum.stVal", "value1", "ST")] + [InlineData("BCUGEF650/CSWI1.Mod.stVal", "value1", "MX")] + [InlineData("BCUGEF650/CSWI1.Mod.q", "value1", "ST")] + [InlineData("BCUGEF650/CSWI10.Mod.stVal", "value1", "SP")] + public void UnrelatedReferencesAndFunctionalConstraintsNeverRewriteWireEvidence( + string reference, string raw, string fc) + => Assert.Equal(raw, IedBrowserStandardStatusPresentation.Format(reference, fc, raw)); + + [Fact] + public void PersistentExplorerMustOccupyAvailableStarRowWhileDockIsIndependent() + { + var root = FindRoot(); + var shell = File.ReadAllText(Path.Combine(root, "MainWindow.PersistentWorkbench.cs")); + var xaml = File.ReadAllText(Path.Combine(root, "MainWindow.xaml")); + Assert.Contains("legacyExplorerLayout.MinHeight = viewport;", shell); + Assert.Contains("MainTabs.VerticalContentAlignment = VerticalAlignment.Stretch;", shell); + Assert.Contains("ExplorerCommandDockRow", xaml); + Assert.Contains("Grid.Row=\"2\" ItemsSource=\"{Binding SelectedDevice.Points}\"", xaml); + Assert.Contains("Height=\"*\"/>", xaml); + } + + private static string FindRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml"))) + return directory.FullName; + directory = directory.Parent; + } + throw new FileNotFoundException("Could not locate MainWindow.xaml."); + } +} From dd983cee773fd353ba517e0d70ff4b91b09d2e29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:59:24 +0700 Subject: [PATCH 023/184] fix(rcb): hydrate only selected verified RCB operational state via association-fenced SSOT read --- MainWindow.IedBrowser.RcbFocusedRead.cs | 85 +++++++++++++++++++ MainWindow.IedBrowser.cs | 28 ++++++ Models/IedBrowserRcbFocusedReadPlan.cs | 33 +++++++ .../ARSAS.Tests/M217QRcbFocusedStateTests.cs | 57 +++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 MainWindow.IedBrowser.RcbFocusedRead.cs create mode 100644 Models/IedBrowserRcbFocusedReadPlan.cs create mode 100644 tests/ARSAS.Tests/M217QRcbFocusedStateTests.cs diff --git a/MainWindow.IedBrowser.RcbFocusedRead.cs b/MainWindow.IedBrowser.RcbFocusedRead.cs new file mode 100644 index 000000000..409666e98 --- /dev/null +++ b/MainWindow.IedBrowser.RcbFocusedRead.cs @@ -0,0 +1,85 @@ +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly IedBrowserFocusedAdmission _iedBrowserRcbFocusedAdmission = new(); + + private void QueueIedBrowserRcbFocusedRead(IedBrowserTreeRow row) + { + if (row is not { Kind: "RCB", Payload: RcbExportRow rcb } || + SelectedDevice is not { IsConnected: true, IsDemo: false } device || + device.LiveDiscoveryModel is null) + return; + var targets = IedBrowserRcbFocusedReadPlan.Build(device.LiveDiscoveryModel, rcb); + if (targets.Count == 0) return; + // Only the currently visible RCB and its three verified read-only + // attribute names; never Read All or operate a device. + var association = device.LiveValues.Association; + var key = IedBrowserFocusedAdmission.PageKey(row.Key, null) + "|RCB"; + if (!_iedBrowserRcbFocusedAdmission.CanStart( + device.DeviceId, association, key, 1)) + return; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(async () => + { + if (!ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(_iedBrowserRenderedPage, row) || + device.LiveValues.Association != association || + !device.IsConnected || device.IsBusy || _iedBrowserReadOperation != null || + !_iedBrowserRcbFocusedAdmission.TryStart( + device.DeviceId, association, key, 1)) + return; + var lease = new IedBrowserRcbCheckLease(device); + using var cts = CancellationTokenSource.CreateLinkedTokenSource( + _applicationCancellation.Token); + cts.CancelAfter(TimeSpan.FromSeconds(18)); + _iedBrowserReadOperation = cts; + var values = new IedBrowserReadResultInbox(() => { }); + var progress = new Progress(_ => { }); + try + { + if (device.IsMonitoring) + await IedBrowserIsolatedReadService.ReadAsync( + device, targets, values, progress, cts.Token); + else + await _runtime.ReadBrowserValuesAsync( + device, targets, values, progress, cts.Token); + if (!lease.IsCurrent(device) || !ReferenceEquals(SelectedDevice, device)) + return; + values.Drain(result => + { + if (result.Error.Length != 0 || + !IedBrowserReadValuePolicy.TryAcceptDisplay( + result.Value, out var scalar, out _)) + return; + device.LiveValues.PublishForAssociation(association, + result.Target.Reference, result.Target.FunctionalConstraint, + scalar, result.Quality, result.DeviceTimestamp, "MMS Read", + result.UtcTimeEvidence, result.QualityEvidence); + }); + _iedBrowserLastLiveVersion = -1; + RefreshIedBrowserVisibleValues(); + } + catch (OperationCanceledException) { /* no stale admission */ } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (lease.IsCurrent(device) && ReferenceEquals(_iedBrowserRenderedPage, row)) + IedBrowserDetailStatus.ToolTip = + "Selected RCB read-only MMS inspection failed: " + ex.Message; + } + finally + { + if (ReferenceEquals(_iedBrowserReadOperation, cts)) + _iedBrowserReadOperation = null; + if (lease.IsCurrent(device) && + ReferenceEquals(_iedBrowserRenderedPage, row)) + UpdateIedBrowserRcbReportDetails(); + RefreshIedBrowserReadButtons(); + } + })); + } +} diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index b3b8df8ed..d8765a15c 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -509,8 +509,11 @@ _iedBrowserNavigation.Current is { } currentFocus && !ReferenceEquals(currentPage, currentFocus) || !string.Equals(selectedDo, _iedBrowserSelectedDoReference, StringComparison.Ordinal))) + { QueueIedBrowserFocusedRead(currentFocus, _iedBrowserSelectedDoReference); + QueueIedBrowserRcbFocusedRead(currentFocus); + } } } @@ -920,6 +923,30 @@ void Put(string name, string value, string authority) row.ObservationTooltip = name + " · " + authority; } + // A focused read-only MMS RCB attribute is admitted to the SAME + // association-bound scalar ledger as Data Model / DataSets. Received + // reports do not prove the current RptEna or reservation state. + var focusedRcbTargets = IedBrowserRcbFocusedReadPlan.Build( + device.LiveDiscoveryModel, rcb); + foreach (var (name, field) in new[] + { + ("Enabled / RptEna", "RptEna"), + ("Reservation", "Resv"), + ("Reserve time (s)", "ResvTms") + }) + { + var target = focusedRcbTargets.FirstOrDefault(t => + t.Reference.EndsWith("." + field, StringComparison.Ordinal)); + if (target is not null && device.IsConnected && + !device.LiveValues.IsOffline && + device.LiveValues.TryGet(target.Reference, target.FunctionalConstraint, + out var actual, preferReport: false) && + !actual.IsStale && + actual.Association == device.LiveValues.Association && + string.Equals(actual.Source, "MMS Read", StringComparison.Ordinal)) + Put(name, actual.Value, "Verified MMS RCB field · current association"); + } + // Association-scoped immutable metadata, never a synthetic report, // reservation state, RptEna, GI, or a newly opened MMS association. var received = device.IsConnected && device.IsMonitoring && @@ -1809,6 +1836,7 @@ private void RenderIedBrowserSelection( RefreshIedBrowserVisibleValues(); RefreshIedBrowserReadButtons(); QueueIedBrowserFocusedRead(row); + QueueIedBrowserRcbFocusedRead(row); // Folder entry opens at its first child, regardless of the scroll // position in the previously inspected RCB, LN or DataSet. if (_iedBrowserDetails.Rows.Count > 0) diff --git a/Models/IedBrowserRcbFocusedReadPlan.cs b/Models/IedBrowserRcbFocusedReadPlan.cs new file mode 100644 index 000000000..0587aab15 --- /dev/null +++ b/Models/IedBrowserRcbFocusedReadPlan.cs @@ -0,0 +1,33 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Small exact-current-MMS RCB inspect plan. Neither a Report frame nor an +/// SCL-only RCB authorizes the state of RptEna. No whole-IED RCB probe. +/// +public static class IedBrowserRcbFocusedReadPlan +{ + public static IReadOnlyList Build( + LiveIedModelDiscoveryDocument? verified, RcbExportRow? selected) + { + if (verified is null || selected is null) return []; + var reference = IedBrowserLiveValues.CanonicalReference(selected.Reference); + var matches = verified.ReportControls.Where(rcb => + string.Equals(IedBrowserLiveValues.CanonicalReference(rcb.Reference), + reference, StringComparison.Ordinal)).Take(2).ToArray(); + if (matches.Length != 1) return []; + var model = matches[0]; + var fc = selected.Buffered ? "BR" : "RP"; + var wire = IedBrowserLiveValues.CanonicalReference(model.Reference); + // Explicit FC name is part of the RCB's observed object identity. + if (!wire.Contains("." + fc + ".", StringComparison.Ordinal)) + return []; + return + [ + new IedBrowserReadTarget(wire + ".RptEna", fc, "Boolean"), + new IedBrowserReadTarget(wire + ".Resv", fc, "Boolean"), + new IedBrowserReadTarget(wire + ".ResvTms", fc, "UInt32") + ]; + } +} diff --git a/tests/ARSAS.Tests/M217QRcbFocusedStateTests.cs b/tests/ARSAS.Tests/M217QRcbFocusedStateTests.cs new file mode 100644 index 000000000..b79a07f4d --- /dev/null +++ b/tests/ARSAS.Tests/M217QRcbFocusedStateTests.cs @@ -0,0 +1,57 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217QRcbFocusedStateTests +{ + private const string Root = "BCUGEF650/LLN0.RP.URB_ALS_SOGI20101"; + private static LiveIedModelDiscoveryDocument Model() => new() + { + ReportControls = [new LiveIedReportControlModel + { + Reference = Root, Name = "URB_ALS_SOGI20101" + }] + }; + + [Fact] + public void ReadOnlyRcbFocusContainsThreeExactCurrentFieldsWithoutWholeIedSweep() + { + var row = new RcbExportRow { Reference = Root, Buffered = false }; + var plan = IedBrowserRcbFocusedReadPlan.Build(Model(), row); + Assert.Equal(3, plan.Count); + Assert.Equal(new[] { "RptEna", "Resv", "ResvTms" }, + plan.Select(x => x.Reference.Split('.').Last()).ToArray()); + Assert.All(plan, x => Assert.Equal("RP", x.FunctionalConstraint)); + Assert.All(plan, x => Assert.StartsWith(Root + ".", x.Reference)); + } + + [Fact] + public void NoSourceOnlyOrDifferentRcbsAreAuthorizedForFocusedRead() + { + Assert.Empty(IedBrowserRcbFocusedReadPlan.Build(null, + new RcbExportRow { Reference = Root })); + Assert.Empty(IedBrowserRcbFocusedReadPlan.Build(Model(), + new RcbExportRow { Reference = Root + "_OTHER" })); + Assert.Empty(IedBrowserRcbFocusedReadPlan.Build(Model(), + new RcbExportRow { Reference = Root, Buffered = true })); + } + + [Fact] + public void ReportTrafficCannotProveRptEnaAndStaleRcbReadCannotCrossAssociation() + { + var row = new RcbExportRow { Reference = Root }; + var notProven = RcbInspectionEvidence.FromDiscovery( + Model().ReportControls.Single(), null); + Assert.Equal("Not verified", notProven.Properties(row, true) + .Single(p => p.Property == "Enabled / RptEna").Value); + var ledger = new IedBrowserLiveValues(); + var old = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(old, Root + ".RptEna", + "RP", "true", "", "", "MMS Read")); + ledger.BeginAssociation(); + Assert.False(ledger.TryGet(Root + ".RptEna", "RP", out _)); + Assert.False(ledger.PublishForAssociation(old, Root + ".RptEna", + "RP", "true", "", "", "MMS Read")); + } +} From f53e771429540a23ba29d610b61f8673cfca8a9b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 21:02:51 +0700 Subject: [PATCH 024/184] fix(nameplate): project exact current FC=DC child even if parent discovery type omits metadata list --- Models/IedBrowserNameplatePresentation.cs | 21 +++++++- ...217RNameplateMissingParentMetadataTests.cs | 48 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/ARSAS.Tests/M217RNameplateMissingParentMetadataTests.cs diff --git a/Models/IedBrowserNameplatePresentation.cs b/Models/IedBrowserNameplatePresentation.cs index 3cbd4c7f6..fd9eca0ca 100644 --- a/Models/IedBrowserNameplatePresentation.cs +++ b/Models/IedBrowserNameplatePresentation.cs @@ -45,10 +45,24 @@ public static bool TryProject(IedBrowserDetailRow row, ArgumentNullException.ThrowIfNull(ledger); ArgumentNullException.ThrowIfNull(engineering); projection = null!; - if (!row.IsDataObject || row.NameplateDcReferences.Count == 0) + if (!row.IsDataObject) return false; + var references = row.NameplateDcReferences; + // Some GetNameList type trees expose the parent DO separately from + // its FC=DC child attributes. The parent can still show an actual + // observed exact child (never an SCL value or a speculative MMS Read). + // The fallback is ONLY for a canonical NamPlt parent and ONLY for + // exact FC=DC leaf observations in the current ledger. + if (references.Count == 0 && + string.Equals(row.Name, "NamPlt", StringComparison.Ordinal) && + row.ReadObjectReference.EndsWith(".NamPlt", StringComparison.Ordinal)) + { + references = PreferredLeaves.Select(leaf => + IedBrowserLiveValues.CanonicalReference(row.ReadObjectReference) + + "." + leaf).ToArray(); + } - foreach (var reference in row.NameplateDcReferences) + foreach (var reference in references) { // Same exact FC and association; never read sibling or guess vendor. if (ledger.TryGet(reference, "DC", out var observed)) @@ -69,6 +83,9 @@ public static bool TryProject(IedBrowserDetailRow row, } // A verified canonical discovery-instance value is an explicitly // HISTORICAL read, not a fresh MMS observation or an SCL declaration. + // Historical discovery projection still requires an exact model + // declaration. A fallback reference with no model leaf must not + // become a SCL-derived present-day or historical value. foreach (var reference in row.NameplateDcReferences) { if (!engineering.TryGet(reference, "DC", out var value, diff --git a/tests/ARSAS.Tests/M217RNameplateMissingParentMetadataTests.cs b/tests/ARSAS.Tests/M217RNameplateMissingParentMetadataTests.cs new file mode 100644 index 000000000..b0b2f9136 --- /dev/null +++ b/tests/ARSAS.Tests/M217RNameplateMissingParentMetadataTests.cs @@ -0,0 +1,48 @@ +using ArIED61850Tester.Models; +using AR.Iec61850.Discovery; + +namespace ARSAS.Tests; + +public sealed class M217RNameplateMissingParentMetadataTests +{ + [Fact] + public void ExactObservedVendorProjectsToNamPltWithoutAParentScalar() + { + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var reference = "BCUGEF650/CSWI1.NamPlt.vendor"; + Assert.True(ledger.PublishForAssociation(ledger.Association, + reference, "DC", "GE Multilin", "", "", "MMS Read")); + var row = new IedBrowserDetailRow("Data", "NamPlt", "Not read") + { + IsDataObject = true, + ReadObjectReference = "BCUGEF650/CSWI1.NamPlt" + }; + var engineering = new IedBrowserCfEvidencePresentation( + ledger, new LiveIedModelDiscoveryDocument(), null); + Assert.True(IedBrowserNameplatePresentation.TryProject( + row, ledger, engineering, out var result)); + Assert.Equal("GE Multilin", result.Value); + Assert.Contains("MMS Read", result.Source); + } + + [Fact] + public void NamPltNeverCopiesOtherObjectOrFunctionalConstraint() + { + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + ledger.PublishForAssociation(ledger.Association, + "BCUGEF650/CSWI2.NamPlt.vendor", "DC", "OTHER", "", "", "MMS Read"); + ledger.PublishForAssociation(ledger.Association, + "BCUGEF650/CSWI1.NamPlt.vendor", "CF", "WRONG", "", "", "MMS Read"); + var engineering = new IedBrowserCfEvidencePresentation( + ledger, new LiveIedModelDiscoveryDocument(), null); + var row = new IedBrowserDetailRow("Data", "NamPlt", "Not read") + { + IsDataObject = true, + ReadObjectReference = "BCUGEF650/CSWI1.NamPlt" + }; + Assert.False(IedBrowserNameplatePresentation.TryProject( + row, ledger, engineering, out _)); + } +} From d92fbfa4d48b5fa2a2324a8ceb592b7dad9ce32b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 21:06:54 +0700 Subject: [PATCH 025/184] fix(datamodel): project phase parent from observed exact cVal MX leaves without shadow value --- MainWindow.IedBrowser.cs | 13 ++++ .../IedBrowserNestedObservedPresentation.cs | 33 +++++++++++ .../M217RNestedPhaseObservationTests.cs | 59 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 Models/IedBrowserNestedObservedPresentation.cs create mode 100644 tests/ARSAS.Tests/M217RNestedPhaseObservationTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d8765a15c..01eba1150 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -713,6 +713,19 @@ private void RefreshIedBrowserVisibleValues() row.ObservationTooltip = composite.Provenance; continue; } + // A phase/intermediate DO may not have its own MMS scalar. + // Reuse ONLY the exact cVal child with already received MX leaves, + // so the parent no longer says Not read while its child is observed. + if (IedBrowserNestedObservedPresentation.TryCompose( + row, device.LiveValues, engineeringUnits, + device.IsMonitoring, out var nested)) + { + row.Value = nested.IsStale ? "Offline · " + nested.Text : nested.Text; + row.ObservationSource = nested.IsStale + ? "Stale derived " + nested.Source : "Derived " + nested.Source; + row.ObservationTooltip = nested.Provenance; + continue; + } // A model-proven compound ST DataSet member can contain two // independently received process leaves. Show both labels rather // than claiming the whole parent was unread or choosing one. diff --git a/Models/IedBrowserNestedObservedPresentation.cs b/Models/IedBrowserNestedObservedPresentation.cs new file mode 100644 index 000000000..5c7131886 --- /dev/null +++ b/Models/IedBrowserNestedObservedPresentation.cs @@ -0,0 +1,33 @@ +namespace ArIED61850Tester.Models; + +/// +/// Display-only exact child reuse for one IEC phase DO whose own structure +/// has no scalar MMS leaf. No read, cache, synthetic phase or angle inference. +/// +public static class IedBrowserNestedObservedPresentation +{ + public static bool TryCompose( + IedBrowserDetailRow parent, IedBrowserLiveValues ledger, + IReadOnlyDictionary units, bool monitoring, + out IedBrowserCompositeObservation observed) + { + observed = null!; + if (!parent.IsDataObject || + !string.Equals(parent.FunctionalConstraint, "MX", StringComparison.Ordinal) || + parent.Name is not ("phsA" or "phsB" or "phsC" or "neut" or "net" or "res") || + !parent.HasChildren) + return false; + // One exact cVal owner, never instCVal, sibling phasors or guessed FC. + var candidates = parent.GetChildren().Where(child => + child.Name == "cVal [MX]" && + string.Equals(child.FunctionalConstraint, "MX", StringComparison.Ordinal)) + .Take(2).ToArray(); + if (candidates.Length != 1) return false; + var child = candidates[0]; + if (child.PolarPlan is null && child.PhasePlans.Count == 0 && + child.VectorComponents.Count == 0) + return false; + return IedBrowserEngineeringComposite.TryCompose( + child, ledger, units, monitoring, out observed); + } +} diff --git a/tests/ARSAS.Tests/M217RNestedPhaseObservationTests.cs b/tests/ARSAS.Tests/M217RNestedPhaseObservationTests.cs new file mode 100644 index 000000000..1875c2772 --- /dev/null +++ b/tests/ARSAS.Tests/M217RNestedPhaseObservationTests.cs @@ -0,0 +1,59 @@ +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217RNestedPhaseObservationTests +{ + [Fact] + public void PhaseParentCanPresentExactObservedCValWithoutInventingAnMmsParent() + { + var magnitude = "LD/MMXU1.A.phsA.cVal.mag.f"; + var angle = "LD/MMXU1.A.phsA.cVal.ang.f"; + var child = new IedBrowserDetailRow("Data", "cVal [MX]", "Not read") + { + FunctionalConstraint = "MX", + VectorComponents = [new IedBrowserVectorComponent("", magnitude, angle, "MX")] + }; + var parent = new IedBrowserDetailRow("Data", "phsA", "Not read") + { + IsDataObject = true, + FunctionalConstraint = "MX", + LoadChildren = () => [child] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + ledger.PublishForAssociation(ledger.Association, + magnitude, "MX", "0", "", "", "MMS Read"); + ledger.PublishForAssociation(ledger.Association, + angle, "MX", "0", "", "", "MMS Read"); + Assert.True(IedBrowserNestedObservedPresentation.TryCompose( + parent, ledger, new Dictionary(), false, out var result)); + Assert.Contains("0", result.Text); + Assert.Contains("∠", result.Text); + } + + [Fact] + public void NoObservedCValOrIncorrectPhaseFunctionalConstraintLeavesParentUnknown() + { + var child = new IedBrowserDetailRow("Data", "instCVal [MX]", "Not read") + { + FunctionalConstraint = "MX", + VectorComponents = [new IedBrowserVectorComponent("", + "LD/MMXU1.A.phsA.instCVal.mag.f", + "LD/MMXU1.A.phsA.instCVal.ang.f", "MX")] + }; + var parent = new IedBrowserDetailRow("Data", "phsA", "Not read") + { + IsDataObject = true, + FunctionalConstraint = "MX", + LoadChildren = () => [child] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + Assert.False(IedBrowserNestedObservedPresentation.TryCompose(parent, + ledger, new Dictionary(), false, out _)); + Assert.False(IedBrowserNestedObservedPresentation.TryCompose(parent with + { FunctionalConstraint = "ST" }, + ledger, new Dictionary(), false, out _)); + } +} From 8a8fcc138f6e117a1084f5fb50135f9c738706c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 21:08:16 +0700 Subject: [PATCH 026/184] test(phasor): protect explicit independent angle glyph without implying phasor pairing --- tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs index 68b60eff5..ed6840da8 100644 --- a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs +++ b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs @@ -118,8 +118,8 @@ public void WrongFcAndConflictingAngleTimestampFailClosed() phase.AngleReference, "MX", "33", "", "timestamp2", "Report"); Assert.True(IedBrowserEngineeringComposite.TryCompose( row, ledger, new Dictionary(), true, out var output)); - Assert.Equal("12; angle 33°", output.Text); - Assert.DoesNotContain("∠", output.Text); + Assert.Equal("12; ∠ (independent) 33°", output.Text); + Assert.DoesNotContain("12 ∠ 33°", output.Text); // never a falsely paired phasor Assert.Contains("independent", output.Provenance); } From c6bbb49f81d428af8a5b0587173013224a0236ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 21:16:03 +0700 Subject: [PATCH 027/184] test(browser): align legacy independent-angle assertions with explicit phasor coherence semantics --- .../IedBrowserCrossThreadVectorParityTests.cs | 14 +++++++------- .../ARSAS.Tests/M214EngineeringHierarchyUxTests.cs | 2 +- .../M214MergedCfAndVectorAuthorityTests.cs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs index d29078836..5e7780eb0 100644 --- a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs +++ b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs @@ -35,9 +35,9 @@ public void DifferentTimestampNeverRendersFalsePairedPhasor() "33", "", "t2", "Report")); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 12 kV; angle 33°", rendered.Value); + Assert.Equal("phsC: 12 kV; ∠ (independent) 33°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); - Assert.DoesNotContain("∠", rendered.Value); + Assert.DoesNotContain(" kV ∠ ", rendered.Value); Assert.Contains(Mag, rendered.Provenance); Assert.Contains(Ang, rendered.Provenance); } @@ -53,10 +53,10 @@ public void MixedReportAndMmsReadPreserveBothValuesButNeverPair() "40", "", "t1", "MMS Read"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 0 kV; angle 40°", rendered.Value); + Assert.Equal("phsC: 0 kV; ∠ (independent) 40°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.Equal("Report + MMS Read", rendered.Source); - Assert.DoesNotContain("∠", rendered.Value); + Assert.DoesNotContain(" kV ∠ ", rendered.Value); } [Fact] @@ -81,9 +81,9 @@ public void AngleOnlyOrInvalidNumericNeverCreatesMagnitudeOrPolarPair() "17", "", "", "Report"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var angleOnly)); - Assert.Equal("phsC: angle 17°", angleOnly.Value); + Assert.Equal("phsC: ∠ (independent) 17°", angleOnly.Value); Assert.Contains("magnitude not observed", angleOnly.Provenance); - Assert.DoesNotContain("∠", angleOnly.Value); + Assert.DoesNotContain(" ∠ 17°", angleOnly.Value); values.PublishForAssociation(association, Mag, "MX", "NaN", "", "", "Report"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( @@ -113,7 +113,7 @@ public void FourModelVerifiedComponentsIncludeNeutralButRespectPerPairCompatibil Assert.Contains("phsA: 12 ∠ 17", result.Value); Assert.Contains("phsB: 12 ∠ 17", result.Value); Assert.Contains("phsC: 12 ∠ 17", result.Value); - Assert.Contains("neut: 12; angle 17", result.Value); + Assert.Contains("neut: 12; ∠ (independent) 17", result.Value); Assert.Contains("neut: angle not paired with magnitude", result.Provenance); Assert.DoesNotContain("neut: 12 ∠", result.Value); values.MarkOffline(); diff --git a/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs index c5d96cb1a..749d9f8ce 100644 --- a/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs +++ b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs @@ -123,7 +123,7 @@ public void ThreePhaseRootUsesCompactPreview_WithoutLosingNeutralEvidence() row with { IsDataObject = false }, ledger, new Dictionary(), true, out var expanded)); Assert.Contains("neut:", expanded.Text); - Assert.Contains("angle 0°", expanded.Text); + Assert.Contains("∠ (independent) 0°", expanded.Text); Assert.DoesNotContain("independent, not paired", expanded.Text); Assert.Contains("not paired with magnitude", expanded.Provenance); } diff --git a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs index 156b51e74..f2199cdd1 100644 --- a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs +++ b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs @@ -55,7 +55,7 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() Assert.True(IedBrowserEngineeringComposite.TryCompose( expanded, ledger, new Dictionary(), true, out var all)); Assert.StartsWith("Independent", all.Source); - Assert.Contains("angle 30", all.Text); + Assert.Contains("∠ (independent) 30", all.Text); Assert.DoesNotContain("independent, not paired", all.Text); Assert.Contains("not paired with magnitude", all.Provenance); Assert.Contains("neut: 3", all.Text); From 094215a6b49cd4e247377d75a6ea4bd45bd1185f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:07:53 +0700 Subject: [PATCH 028/184] fix(browser): prefer verified CF engineering units and remove false independent phasor labels --- MainWindow.IedBrowser.cs | 8 ++++++++ Models/IedBrowserCfEvidencePresentation.cs | 17 +++++++++++++++++ Models/IedBrowserEngineeringComposite.cs | 16 +++++++++------- Models/IedBrowserObservedVectorPresentation.cs | 10 ++++++---- Models/IedBrowserReadPlan.cs | 6 ++++++ Models/IedBrowserStructuredDetails.cs | 1 + 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 01eba1150..bc5d593a4 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -845,6 +845,14 @@ private void RefreshIedBrowserVisibleValues() row.EngineeringUnitReference.Length == 0 ? row.ValueReference : row.EngineeringUnitReference, row.FunctionalConstraint)); + // An expanded MX ang leaf is in degrees, not a fabricated pair. + // cVal parent handles coherent phasor composition separately. + else if (row.EvidenceField.Length == 0 && + observedReference.EndsWith(".ang.f", StringComparison.Ordinal) && + row.FunctionalConstraint == "MX") + display = IedBrowserEngineeringValuePresentation.Format( + display, IedBrowserEngineeringValuePresentation.ResolveVectorAngleUnit( + engineeringUnits, observedReference, "MX")); row.Value = live.IsStale ? "Offline · " + display : display; } UpdateIedBrowserGooseDetails(); diff --git a/Models/IedBrowserCfEvidencePresentation.cs b/Models/IedBrowserCfEvidencePresentation.cs index 49c86ef6e..618259eb7 100644 --- a/Models/IedBrowserCfEvidencePresentation.cs +++ b/Models/IedBrowserCfEvidencePresentation.cs @@ -223,6 +223,23 @@ public static string RenderCfValue(string reference, string fc, string raw) index.Add(key, composed); else if (!string.Equals(previous, composed, StringComparison.Ordinal)) index[key] = fresh ? composed : null; + + // A live IEC CF pair is more authoritative than generic + // SignalDefinition.Unit metadata on individual magnitude leaves. + // Do not apply a phase unit to a sibling, angle, quality or LN. + if (!fresh) continue; + index[key] = composed; + foreach (var leaf in new[] + { + ".cVal.mag.f", ".instCVal.mag.f", ".mag.f", ".instMag.f" + }) + { + var exact = owner + leaf + "|MX"; + // Only exact paths whose owners carry this verified CF pair. + // Avoid adding aliases when the live schema has no such leaf. + if (index.ContainsKey(exact)) + index[exact] = composed; + } } return index; } diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index fd9103049..3766b6435 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -175,14 +175,16 @@ public static bool TryCompose( CultureInfo.InvariantCulture, out var independentNumber) && double.IsFinite(independentNumber)) { - // Two real values can be displayed separately. Never add ∠ - // unless the actual report/read evidence passed CanPair. + // Keep the operator's Value column clean: do not claim a + // phasor from mismatched timestamp/source observations. + // The real angle remains accessible at ang [MX] and in the + // provenance tooltip, never discarded from the SSOT ledger. var independentUnit = IedBrowserEngineeringValuePresentation.ResolveVectorAngleUnit( unitIndex, plan.AngleReference, plan.FunctionalConstraint); - text += "; ∠ (independent) " + IedBrowserEngineeringValuePresentation.Format( - independentAngle.Value, independentUnit); - references.Add(plan.AngleReference + "[independent " + - plan.FunctionalConstraint + "]"); + references.Add(plan.AngleReference + "[separate " + + plan.FunctionalConstraint + "]: " + + IedBrowserEngineeringValuePresentation.Format( + independentAngle.Value, independentUnit)); } components.Add(plan.Phase.Length == 0 ? text : plan.Phase + ": " + text); } @@ -210,7 +212,7 @@ private static bool TryIndependent(IedBrowserDetailRow row, out var independent)) return false; result = new IedBrowserCompositeObservation( - independent.Value, "Independent " + independent.Source, + independent.Value, independent.Source, independent.Provenance, independent.IsStale); return true; } diff --git a/Models/IedBrowserObservedVectorPresentation.cs b/Models/IedBrowserObservedVectorPresentation.cs index 2b4865c3f..1b4f46cb2 100644 --- a/Models/IedBrowserObservedVectorPresentation.cs +++ b/Models/IedBrowserObservedVectorPresentation.cs @@ -68,12 +68,14 @@ public static bool TryFormat( text += " ∠ " + rendered; else { - // The field is independently observed, not a phasor angle - // pair. Keep the reason in provenance, not in Value cells. - text += (text.Length == 0 ? "" : "; ") + "∠ (independent) " + rendered; + // IEDScout-like clean Value: when no coherent pair exists, + // show a magnitude alone (or just ° if only ang exists). + // Never append a pseudo phasor or "independent" jargon. + if (!hasMag) text = rendered; unpaired.Add(part.Label + ": " + (hasMag ? "angle not paired with magnitude" - : "magnitude not observed")); + : "magnitude not observed") + + " · angle " + rendered + " [" + part.AngleReference + "]"); } Register(part.AngleReference, angle); } diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 1547d453a..b0ca02ce6 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -154,6 +154,12 @@ private static int FocusPriority(string kind, IedBrowserReadTarget target) reference.EndsWith(".ActSG", StringComparison.Ordinal) ? 0 : reference.EndsWith(".LActTm", StringComparison.Ordinal) ? 2 : 3; var fc = (target.FunctionalConstraint ?? "").ToUpperInvariant(); + if (fc == "DC" && reference.Contains(".NamPlt.", StringComparison.Ordinal) && + (reference.EndsWith(".vendor", StringComparison.Ordinal) || + reference.EndsWith(".d", StringComparison.Ordinal) || + reference.EndsWith(".swRev", StringComparison.Ordinal) || + reference.EndsWith(".configRev", StringComparison.Ordinal))) + return 0; if (fc == "ST" && (reference.EndsWith(".stVal", StringComparison.Ordinal) || reference.EndsWith(".posVal", StringComparison.Ordinal))) diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index e1220e335..81280265a 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -337,6 +337,7 @@ private static IReadOnlyList BuildCrossFcRoots( string.Join(", ", fcs)) { IsDataObject = true, + ReadObjectReference = qualified, ValueReference = chosen, FunctionalConstraint = fc, EngineeringUnitReference = scalar ?? polar?.MagnitudeReference ?? "", From b7bebe035c7945877a85b7dca82fccea9e3beb9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:10:55 +0700 Subject: [PATCH 029/184] fix(browser): hydrate expanded live CF/DC attribute subtrees through existing SSOT read pipeline --- MainWindow.IedBrowser.cs | 74 +++++++++++++++++-- ...dBrowserVisibleConfigurationReadRequest.cs | 59 +++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 Models/IedBrowserVisibleConfigurationReadRequest.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index bc5d593a4..d938c8e72 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -276,7 +276,8 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // A dedicated read-only MMS association is used if monitoring is active. // Its bounded reads never interfere with the reporting session. - var automatic = sender is Button { Tag: "Auto" }; + var configRequest = (sender as Button)?.Tag as IedBrowserVisibleConfigurationReadRequest; + var automatic = sender is Button { Tag: "Auto" } || configRequest is not null; var readAll = sender is Button { Tag: "All" }; if (!readAll) { @@ -306,11 +307,26 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) model, currentPage, readAll, selectedDo); if (automatic) { - // Exact selected LN or SGCB only. Priority is declared IEC order, - // not an all-IED sweep; existing Report/Read observations are reused. - targets = IedBrowserReadPlan.BuildFocused( - device.LiveDiscoveryModel, currentPage, device.LiveValues, - selectedDoReference: selectedDo); + if (configRequest is not null) + { + // Expanded phase/Nameplate metadata: exact missing CF/DC leaves + // of the current verified LN. A stale/collapsed row may not read. + if (!ReferenceEquals(_iedBrowserRenderedPage, currentPage) || + !_iedBrowserDetails.Rows.Any(x => x.IsExpanded && + string.Equals(x.ReadObjectReference, + configRequest.Scope, StringComparison.Ordinal))) + return; + targets = IedBrowserVisibleConfigurationReadRequest.Build( + device.LiveDiscoveryModel, currentPage, + configRequest.Scope, device.LiveValues); + } + else + { + // Existing bounded process-value hydration; no IED-wide scan. + targets = IedBrowserReadPlan.BuildFocused( + device.LiveDiscoveryModel, currentPage, device.LiveValues, + selectedDoReference: selectedDo); + } } if (targets.Count == 0) { @@ -327,11 +343,13 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // reaches the read handler. A queued Dispatcher callback is NOT a read. // Rejected/busy/cancelled preflight cannot poison a future visit. var automaticKey = automatic && currentPage is not null - ? IedBrowserFocusedAdmission.PageKey(currentPage.Key, selectedDo) + ? IedBrowserFocusedAdmission.PageKey( + currentPage.Key, configRequest?.Scope ?? selectedDo) : null; if (automaticKey is not null && !_iedBrowserAutoReadAdmission.TryStart(device.DeviceId, device.LiveValues.Association, automaticKey, + configRequest is not null ? 1 : currentPage!.Kind == "SGCB" ? 3 : currentPage.Kind is "LN" or "DO" ? 2 : 1)) return; @@ -514,6 +532,10 @@ _iedBrowserNavigation.Current is { } currentFocus && _iedBrowserSelectedDoReference); QueueIedBrowserRcbFocusedRead(currentFocus); } + if (ReferenceEquals(SelectedDevice, sourceDevice) && + _iedBrowserNavigation.Current is { Kind: "LN" } && + _iedBrowserReadOperation is null) + QueueFirstExpandedVisibleConfigurationRead(); } } @@ -1762,9 +1784,47 @@ private void ToggleIedBrowserDetails(IedBrowserDetailRow row) IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows; _iedBrowserLastLiveVersion = -1; RefreshIedBrowserVisibleValues(); + if (row.IsExpanded) + QueueIedBrowserVisibleConfigurationRead(row); + })); + } + + // Read an expanded IEC CF/DC subtree through the existing scoped, + // association-fenced MMS Read pipeline; never shadow canonical values. + private void QueueIedBrowserVisibleConfigurationRead(IedBrowserDetailRow row) + { + if (row.ReadObjectReference.Length == 0 || + SelectedDevice is not { IsConnected: true, IsDemo: false } device || + _iedBrowserNavigation.Current is not { Kind: "LN" } current || + !ReferenceEquals(_iedBrowserRenderedPage, current)) + return; + var scope = row.ReadObjectReference; + var association = device.LiveValues.Association; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!ReferenceEquals(SelectedDevice, device) || + device.LiveValues.Association != association || + !_iedBrowserDetails.Rows.Contains(row) || !row.IsExpanded || + !ReferenceEquals(current, _iedBrowserRenderedPage) || + device.IsBusy || _iedBrowserReadOperation is not null) return; + IedBrowserReadValues_Click( + new Button { Tag = new IedBrowserVisibleConfigurationReadRequest(scope) }, + new RoutedEventArgs()); })); } + private void QueueFirstExpandedVisibleConfigurationRead() + { + if (_iedBrowserDetails.Rows.FirstOrDefault(x => + x.IsExpanded && x.ReadObjectReference.Length > 0 && + _iedBrowserAutoReadAdmission.CanStart( + SelectedDevice!.DeviceId, SelectedDevice.LiveValues.Association, + IedBrowserFocusedAdmission.PageKey( + _iedBrowserNavigation.Current!.Key, x.ReadObjectReference), 1)) + is { } row) + QueueIedBrowserVisibleConfigurationRead(row); + } + private void QueueIedBrowserFocusedRead(IedBrowserTreeRow row, string? selectedDoReference = null) { diff --git a/Models/IedBrowserVisibleConfigurationReadRequest.cs b/Models/IedBrowserVisibleConfigurationReadRequest.cs new file mode 100644 index 000000000..54e5a2d63 --- /dev/null +++ b/Models/IedBrowserVisibleConfigurationReadRequest.cs @@ -0,0 +1,59 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// One-shot CF/DC hydration for an expanded data-model subobject. Uses only +/// existing verified discovery attributes and an association-bound SSOT. +/// Never scans other LNs, never reads CO, never supplies guessed values. +/// +public sealed record IedBrowserVisibleConfigurationReadRequest(string Scope) +{ + public static IReadOnlyList Build( + LiveIedModelDiscoveryDocument? model, IedBrowserTreeRow? page, + string scope, IedBrowserLiveValues ledger, int budget = 18) + { + if (model is null || page is not { Kind: "LN", + Payload: LiveIedLogicalNodeModel } || + string.IsNullOrWhiteSpace(scope) || ledger.IsOffline || + budget <= 0) return []; + + var canonical = IedBrowserLiveValues.CanonicalReference(scope); + var prefix = canonical + "."; + var candidates = model.LogicalDevices + .SelectMany(ld => ld.LogicalNodes) + .Where(ln => ReferenceEquals(ln, page.Payload)) + .SelectMany(ln => ln.DataObjects) + .Where(obj => canonical == IedBrowserLiveValues.CanonicalReference(obj.Reference) || + canonical.StartsWith( + IedBrowserLiveValues.CanonicalReference(obj.Reference) + ".", + StringComparison.Ordinal)) + .SelectMany(obj => obj.Attributes) + .Where(a => a.FunctionalConstraint is "CF" or "DC" && + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + .StartsWith(prefix, StringComparison.Ordinal)) + .Where(a => !ledger.TryGet(a.MmsReference, a.FunctionalConstraint, + out var observed) || observed.IsStale || + observed.Association != ledger.Association) + .GroupBy(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference) + + "|" + a.FunctionalConstraint, StringComparer.Ordinal) + .Where(g => g.Count() == 1) + .Select(g => g.First()) + .OrderBy(a => Priority(IedBrowserLiveValues.CanonicalReference(a.MmsReference), + a.FunctionalConstraint)) + .ThenBy(a => a.MmsReference, StringComparer.Ordinal) + .Take(Math.Min(24, budget)) + .Select(a => new IedBrowserReadTarget(a.MmsReference, + a.FunctionalConstraint, + string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType)) + .ToArray(); + return candidates; + } + + private static int Priority(string reference, string fc) + => fc == "CF" && (reference.EndsWith(".units.SIUnit", StringComparison.Ordinal) || + reference.EndsWith(".units.multiplier", StringComparison.Ordinal)) + ? 0 + : fc == "DC" || reference.EndsWith(".db", StringComparison.Ordinal) || + reference.EndsWith(".dbAng", StringComparison.Ordinal) ? 1 : 2; +} From 0abfdbfe7e6b956fd5403883e8736b2626e8eac3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:12:52 +0700 Subject: [PATCH 030/184] test(browser): preserve phasor pairing safety and verify live CF/DC focused hydration --- MainWindow.IedBrowser.cs | 6 +- ...dBrowserVisibleConfigurationReadRequest.cs | 2 +- .../IedBrowserCrossThreadVectorParityTests.cs | 9 +- .../IedBrowserEngineeringCompositeTests.cs | 6 +- .../M214EngineeringHierarchyUxTests.cs | 3 +- .../M214MergedCfAndVectorAuthorityTests.cs | 3 +- ...eeringUnitsAndVisibleConfigurationTests.cs | 126 ++++++++++++++++++ 7 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 tests/ARSAS.Tests/M217SVerifiedEngineeringUnitsAndVisibleConfigurationTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d938c8e72..6bc5c352d 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -532,7 +532,8 @@ _iedBrowserNavigation.Current is { } currentFocus && _iedBrowserSelectedDoReference); QueueIedBrowserRcbFocusedRead(currentFocus); } - if (ReferenceEquals(SelectedDevice, sourceDevice) && + if (configRequest is null && + ReferenceEquals(SelectedDevice, sourceDevice) && _iedBrowserNavigation.Current is { Kind: "LN" } && _iedBrowserReadOperation is null) QueueFirstExpandedVisibleConfigurationRead(); @@ -1815,12 +1816,13 @@ private void QueueIedBrowserVisibleConfigurationRead(IedBrowserDetailRow row) private void QueueFirstExpandedVisibleConfigurationRead() { - if (_iedBrowserDetails.Rows.FirstOrDefault(x => + if (_iedBrowserDetails.Rows.Where(x => x.IsExpanded && x.ReadObjectReference.Length > 0 && _iedBrowserAutoReadAdmission.CanStart( SelectedDevice!.DeviceId, SelectedDevice.LiveValues.Association, IedBrowserFocusedAdmission.PageKey( _iedBrowserNavigation.Current!.Key, x.ReadObjectReference), 1)) + .OrderByDescending(x => x.Depth).FirstOrDefault() is { } row) QueueIedBrowserVisibleConfigurationRead(row); } diff --git a/Models/IedBrowserVisibleConfigurationReadRequest.cs b/Models/IedBrowserVisibleConfigurationReadRequest.cs index 54e5a2d63..040f2072e 100644 --- a/Models/IedBrowserVisibleConfigurationReadRequest.cs +++ b/Models/IedBrowserVisibleConfigurationReadRequest.cs @@ -29,7 +29,7 @@ public static IReadOnlyList Build( IedBrowserLiveValues.CanonicalReference(obj.Reference) + ".", StringComparison.Ordinal)) .SelectMany(obj => obj.Attributes) - .Where(a => a.FunctionalConstraint is "CF" or "DC" && + .Where(a => (a.FunctionalConstraint is "CF" or "DC") && IedBrowserLiveValues.CanonicalReference(a.MmsReference) .StartsWith(prefix, StringComparison.Ordinal)) .Where(a => !ledger.TryGet(a.MmsReference, a.FunctionalConstraint, diff --git a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs index 5e7780eb0..6ee9e52c8 100644 --- a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs +++ b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs @@ -35,7 +35,7 @@ public void DifferentTimestampNeverRendersFalsePairedPhasor() "33", "", "t2", "Report")); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 12 kV; ∠ (independent) 33°", rendered.Value); + Assert.Equal("phsC: 12 kV", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.DoesNotContain(" kV ∠ ", rendered.Value); Assert.Contains(Mag, rendered.Provenance); @@ -53,7 +53,7 @@ public void MixedReportAndMmsReadPreserveBothValuesButNeverPair() "40", "", "t1", "MMS Read"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 0 kV; ∠ (independent) 40°", rendered.Value); + Assert.Equal("phsC: 0 kV", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.Equal("Report + MMS Read", rendered.Source); Assert.DoesNotContain(" kV ∠ ", rendered.Value); @@ -81,7 +81,7 @@ public void AngleOnlyOrInvalidNumericNeverCreatesMagnitudeOrPolarPair() "17", "", "", "Report"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var angleOnly)); - Assert.Equal("phsC: ∠ (independent) 17°", angleOnly.Value); + Assert.Equal("phsC: 17°", angleOnly.Value); Assert.Contains("magnitude not observed", angleOnly.Provenance); Assert.DoesNotContain(" ∠ 17°", angleOnly.Value); values.PublishForAssociation(association, Mag, "MX", @@ -113,9 +113,10 @@ public void FourModelVerifiedComponentsIncludeNeutralButRespectPerPairCompatibil Assert.Contains("phsA: 12 ∠ 17", result.Value); Assert.Contains("phsB: 12 ∠ 17", result.Value); Assert.Contains("phsC: 12 ∠ 17", result.Value); - Assert.Contains("neut: 12; ∠ (independent) 17", result.Value); + Assert.Contains("neut: 12", result.Value); Assert.Contains("neut: angle not paired with magnitude", result.Provenance); Assert.DoesNotContain("neut: 12 ∠", result.Value); + Assert.DoesNotContain("independent", result.Value, StringComparison.OrdinalIgnoreCase); values.MarkOffline(); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, parts, new Dictionary(), true, out var stale)); diff --git a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs index ed6840da8..f36762eee 100644 --- a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs +++ b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs @@ -118,9 +118,9 @@ public void WrongFcAndConflictingAngleTimestampFailClosed() phase.AngleReference, "MX", "33", "", "timestamp2", "Report"); Assert.True(IedBrowserEngineeringComposite.TryCompose( row, ledger, new Dictionary(), true, out var output)); - Assert.Equal("12; ∠ (independent) 33°", output.Text); - Assert.DoesNotContain("12 ∠ 33°", output.Text); // never a falsely paired phasor - Assert.Contains("independent", output.Provenance); + Assert.Equal("12", output.Text); // angle remains accessible on its child + Assert.DoesNotContain("∠", output.Text); // never a falsely paired phasor + Assert.Contains("33°", output.Provenance); } [Fact] diff --git a/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs index 749d9f8ce..fbf25c0fc 100644 --- a/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs +++ b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs @@ -123,7 +123,8 @@ public void ThreePhaseRootUsesCompactPreview_WithoutLosingNeutralEvidence() row with { IsDataObject = false }, ledger, new Dictionary(), true, out var expanded)); Assert.Contains("neut:", expanded.Text); - Assert.Contains("∠ (independent) 0°", expanded.Text); + Assert.Contains("neut: 0", expanded.Text); + Assert.DoesNotContain("independent", expanded.Text); Assert.DoesNotContain("independent, not paired", expanded.Text); Assert.Contains("not paired with magnitude", expanded.Provenance); } diff --git a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs index f2199cdd1..14bdad7e3 100644 --- a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs +++ b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs @@ -55,7 +55,8 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() Assert.True(IedBrowserEngineeringComposite.TryCompose( expanded, ledger, new Dictionary(), true, out var all)); Assert.StartsWith("Independent", all.Source); - Assert.Contains("∠ (independent) 30", all.Text); + Assert.DoesNotContain("∠", all.Text); + Assert.Contains("angle 30", all.Provenance); Assert.DoesNotContain("independent, not paired", all.Text); Assert.Contains("not paired with magnitude", all.Provenance); Assert.Contains("neut: 3", all.Text); diff --git a/tests/ARSAS.Tests/M217SVerifiedEngineeringUnitsAndVisibleConfigurationTests.cs b/tests/ARSAS.Tests/M217SVerifiedEngineeringUnitsAndVisibleConfigurationTests.cs new file mode 100644 index 000000000..763711e04 --- /dev/null +++ b/tests/ARSAS.Tests/M217SVerifiedEngineeringUnitsAndVisibleConfigurationTests.cs @@ -0,0 +1,126 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217SVerifiedEngineeringUnitsAndVisibleConfigurationTests +{ + private const string Owner = "LD/MMXU1.A"; + private const string Phase = Owner + ".phsA"; + private static LiveIedDataAttributeModel Attr(string fc, string subpath) + => new() + { + ObjectReference = Owner, + AttributePath = "A." + subpath, + MmsReference = "LD/MMXU1$" + fc + "$A$" + + subpath.Replace('.', '$'), + FunctionalConstraint = fc, + SclBType = subpath.Contains("SIUnit", StringComparison.Ordinal) + ? "Enum" : "FLOAT32" + }; + + private static (LiveIedModelDiscoveryDocument Model, IedBrowserTreeRow Ln) + Fixture() + { + var ln = new LiveIedLogicalNodeModel + { + Name = "MMXU1", + DataObjects = [new LiveIedDataObjectModel + { + Name = "A", Reference = Owner, InferredCdc = "WYE", + Attributes = + [ + Attr("MX", "phsA.cVal.mag.f"), + Attr("MX", "phsA.cVal.ang.f"), + Attr("CF", "phsA.units.SIUnit"), + Attr("CF", "phsA.units.multiplier"), + Attr("CF", "phsA.db"), + Attr("CF", "phsA.dbAng"), + Attr("CF", "phsA.rangeC"), + Attr("DC", "phsA.d"), + Attr("CF", "phsB.units.SIUnit"), + Attr("CF", "phsB.units.multiplier") + ] + }] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [ln] + }] + }; + return (model, new IedBrowserTreeRow( + "model/ln/LD/MMXU1", "MMXU1", "LN", 2, ln)); + } + + [Fact] + public void LiveVerifiedCfOverridesConflictingLeafMetadataOnlyInItsOwnPhase() + { + var (model, _) = Fixture(); + var ledger = new IedBrowserLiveValues(); + var session = ledger.BeginAssociation(); + ledger.PublishForAssociation(session, Phase + ".units.SIUnit", + "CF", "5", "", "", "MMS Read"); + ledger.PublishForAssociation(session, Phase + ".units.multiplier", + "CF", "3", "", "", "MMS Read"); + var signals = new[] + { + new SignalDefinition + { + ObjectReference = Phase + ".cVal.mag.f", + FunctionalConstraint = "MX", Unit = "A" + }, + new SignalDefinition + { + ObjectReference = Owner + ".phsB.cVal.mag.f", + FunctionalConstraint = "MX", Unit = "V" + } + }; + var cf = new IedBrowserCfEvidencePresentation(ledger, model, null); + var index = cf.BuildVerifiedUnits(signals, model); + Assert.True(cf.TryFormatUnitsParent(Phase + ".units", "CF", + out var unit, out _)); + Assert.Equal("kA", unit); + Assert.Equal("kA", IedBrowserEngineeringValuePresentation.ResolveUnit( + index, Phase + ".cVal.mag.f", "MX")); + Assert.Equal("V", IedBrowserEngineeringValuePresentation.ResolveUnit( + index, Owner + ".phsB.cVal.mag.f", "MX")); + Assert.Equal("0 kA", IedBrowserEngineeringValuePresentation.Format("0", + IedBrowserEngineeringValuePresentation.ResolveUnit( + index, Phase + ".cVal.mag.f", "MX"))); + } + + [Fact] + public void ExpandedPhaseReadsOnlyItsMissingVerifiedCfAndDcLeaves() + { + var (model, ln) = Fixture(); + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var targets = IedBrowserVisibleConfigurationReadRequest.Build( + model, ln, Phase, ledger); + Assert.Equal(6, targets.Count); + Assert.All(targets, target => + { + Assert.StartsWith(Phase + ".", + IedBrowserLiveValues.CanonicalReference(target.Reference)); + Assert.Contains(target.FunctionalConstraint, new[] { "CF", "DC" }); + }); + Assert.Contains(targets, x => IedBrowserLiveValues.CanonicalReference(x.Reference) + .EndsWith(".dbAng", StringComparison.Ordinal)); + Assert.Contains(targets, x => IedBrowserLiveValues.CanonicalReference(x.Reference) + .EndsWith(".d", StringComparison.Ordinal)); + ledger.PublishForAssociation(ledger.Association, Phase + ".db", + "CF", "10", "", "", "MMS Read"); + var remaining = IedBrowserVisibleConfigurationReadRequest.Build( + model, ln, Phase, ledger); + Assert.Equal(5, remaining.Count); + Assert.DoesNotContain(remaining, x => + IedBrowserLiveValues.CanonicalReference(x.Reference).EndsWith( + ".db", StringComparison.Ordinal)); + Assert.Empty(IedBrowserVisibleConfigurationReadRequest.Build( + model, ln, Owner + ".phsC", ledger)); + Assert.Empty(IedBrowserVisibleConfigurationReadRequest.Build( + null, ln, Phase, ledger)); + } +} From adfedc36ad1d31b34910cfc17cc4e39dbbe632f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:13:59 +0700 Subject: [PATCH 031/184] chore(browser): maintain clean-room identifier policy in phasor presenter --- Models/IedBrowserObservedVectorPresentation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/IedBrowserObservedVectorPresentation.cs b/Models/IedBrowserObservedVectorPresentation.cs index 1b4f46cb2..598752eee 100644 --- a/Models/IedBrowserObservedVectorPresentation.cs +++ b/Models/IedBrowserObservedVectorPresentation.cs @@ -68,7 +68,7 @@ public static bool TryFormat( text += " ∠ " + rendered; else { - // IEDScout-like clean Value: when no coherent pair exists, + // Clean operator Value: when no coherent pair exists, // show a magnitude alone (or just ° if only ang exists). // Never append a pseudo phasor or "independent" jargon. if (!hasMag) text = rendered; From d7ea832e9e51a0af5bd153073372fd22fed14409 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:19:20 +0700 Subject: [PATCH 032/184] test(browser): assert canonical Report provenance without operator-facing independent jargon --- tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs index 14bdad7e3..9c8339993 100644 --- a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs +++ b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs @@ -54,7 +54,7 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() var expanded = row with { IsDataObject = false }; Assert.True(IedBrowserEngineeringComposite.TryCompose( expanded, ledger, new Dictionary(), true, out var all)); - Assert.StartsWith("Independent", all.Source); + Assert.Equal("Report", all.Source); Assert.DoesNotContain("∠", all.Text); Assert.Contains("angle 30", all.Provenance); Assert.DoesNotContain("independent, not paired", all.Text); From 95d2bb907e33ff5b9da2503712e61393c202999a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:03:21 +0700 Subject: [PATCH 033/184] fix(browser): hydrate verified measurement CF units from canonical model for DataSets and Reports --- MainWindow.IedBrowser.RcbFocusedRead.cs | 36 +++- MainWindow.IedBrowser.cs | 2 +- .../IedBrowserMeasurementMetadataReadPlan.cs | 92 ++++++++++ Models/IedBrowserReadPlan.cs | 18 +- ...ataSetReportCanonicalUnitHydrationTests.cs | 170 ++++++++++++++++++ 5 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 Models/IedBrowserMeasurementMetadataReadPlan.cs create mode 100644 tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs diff --git a/MainWindow.IedBrowser.RcbFocusedRead.cs b/MainWindow.IedBrowser.RcbFocusedRead.cs index 409666e98..5c6e5a093 100644 --- a/MainWindow.IedBrowser.RcbFocusedRead.cs +++ b/MainWindow.IedBrowser.RcbFocusedRead.cs @@ -16,7 +16,11 @@ private void QueueIedBrowserRcbFocusedRead(IedBrowserTreeRow row) device.LiveDiscoveryModel is null) return; var targets = IedBrowserRcbFocusedReadPlan.Build(device.LiveDiscoveryModel, rcb); - if (targets.Count == 0) return; + if (targets.Count == 0) + { + QueueIedBrowserReportUnitRead(row); + return; + } // Only the currently visible RCB and its three verified read-only // attribute names; never Read All or operate a device. var association = device.LiveValues.Association; @@ -79,7 +83,37 @@ await _runtime.ReadBrowserValuesAsync( ReferenceEquals(_iedBrowserRenderedPage, row)) UpdateIedBrowserRcbReportDetails(); RefreshIedBrowserReadButtons(); + if (lease.IsCurrent(device) && + ReferenceEquals(_iedBrowserRenderedPage, row)) + QueueIedBrowserReportUnitRead(row); + } + })); + } + + private void QueueIedBrowserReportUnitRead(IedBrowserTreeRow row) + { + if (row.Kind != "RCB" || + SelectedDevice is not { IsConnected: true, IsDemo: false } device || + device.LiveDiscoveryModel is null) return; + var association = device.LiveValues.Association; + var key = IedBrowserFocusedAdmission.PageKey(row.Key, "|unit-CF"); + if (!_iedBrowserAutoReadAdmission.CanStart( + device.DeviceId, association, key, 3)) return; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!ReferenceEquals(SelectedDevice, device) || + device.LiveValues.Association != association || + !ReferenceEquals(_iedBrowserNavigation.Current, row) || + !ReferenceEquals(_iedBrowserRenderedPage, row) || + device.IsBusy || _iedBrowserReadOperation is not null) return; + if (IedBrowserMeasurementMetadataReadPlan.BuildForPage( + device.LiveDiscoveryModel, row, device.LiveValues).Count == 0) + { + _iedBrowserAutoReadAdmission.Complete(key); + return; } + IedBrowserReadValues_Click(new Button { Tag = "ReportUnitAuto" }, + new RoutedEventArgs()); })); } } diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 6bc5c352d..392fdf0ee 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1839,7 +1839,7 @@ row.Kind is not ("SGCB" or "LN" or "DO" or "DA" or "DataSet") || var selectedDo = row.Kind == "LN" ? selectedDoReference : null; var key = IedBrowserFocusedAdmission.PageKey(row.Key, selectedDo); var maxAttempts = row.Kind == "SGCB" ? 3 : - row.Kind is "LN" or "DO" ? 2 : 1; + row.Kind is "LN" or "DO" or "DataSet" ? 3 : 1; if (!_iedBrowserAutoReadAdmission.CanStart( device.DeviceId, association, key, maxAttempts)) return; diff --git a/Models/IedBrowserMeasurementMetadataReadPlan.cs b/Models/IedBrowserMeasurementMetadataReadPlan.cs new file mode 100644 index 000000000..27b9e734d --- /dev/null +++ b/Models/IedBrowserMeasurementMetadataReadPlan.cs @@ -0,0 +1,92 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Hydrates IEC measurement metadata for one EXACT selected live DataSet. +/// These CF reads enrich the existing per-IED canonical ledger; they are not +/// new DataSet members, not Report mutations, and never write the IED. +/// +public static class IedBrowserMeasurementMetadataReadPlan +{ + public static IReadOnlyList BuildForPage( + LiveIedModelDiscoveryDocument? model, IedBrowserTreeRow? page, + IedBrowserLiveValues ledger, int budget = 24) + { + var reference = page?.Payload switch + { + LiveIedDataSetModel set when page.Kind == "DataSet" => set.Reference, + RcbExportRow rcb when page.Kind == "RCB" => rcb.DataSetReference, + _ => "" + }; + return Build(model, reference, ledger, budget); + } + + public static IReadOnlyList Build( + LiveIedModelDiscoveryDocument? model, string? dataSetReference, + IedBrowserLiveValues ledger, int budget = 24) + { + ArgumentNullException.ThrowIfNull(ledger); + if (model is null || ledger.IsOffline || budget <= 0 || + string.IsNullOrWhiteSpace(dataSetReference)) return []; + + var identity = IedBrowserLiveValues.CanonicalReference(dataSetReference); + var matching = model.DataSets.Where(s => + string.Equals(IedBrowserLiveValues.CanonicalReference(s.Reference), + identity, StringComparison.Ordinal)).Take(2).ToArray(); + if (matching.Length != 1) return []; + + // The current MMS discovery model, not SCL, is authority for an FCDA + // and for each CF leaf. Ambiguous duplicate live declarations fail closed. + var attributes = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects).SelectMany(obj => obj.Attributes) + .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)).ToArray(); + var unique = attributes.GroupBy(a => + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + "|" + + (a.FunctionalConstraint ?? "").Trim().ToUpperInvariant(), + StringComparer.Ordinal) + .Where(g => g.Count() == 1) + .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); + var members = matching[0].Members + .Where(m => string.Equals(m.FunctionalConstraint, "MX", + StringComparison.OrdinalIgnoreCase)) + .Select(m => IedBrowserLiveValues.CanonicalReference(m.Reference)) + .Where(m => m.Length > 0).Distinct(StringComparer.Ordinal).ToArray(); + var owners = new HashSet(StringComparer.Ordinal); + foreach (var attr in attributes) + { + if (!string.Equals(attr.FunctionalConstraint, "MX", + StringComparison.OrdinalIgnoreCase)) continue; + var reference = IedBrowserLiveValues.CanonicalReference(attr.MmsReference); + const string suffix = ".cVal.mag.f"; + if (!reference.EndsWith(suffix, StringComparison.Ordinal)) continue; + var phase = reference[..^suffix.Length]; + // Member scope includes exact FCD and FCDA subobjects, but not + // other logical nodes, siblings, or similarly prefixed objects. + if (members.Any(m => phase == m || + phase.StartsWith(m + ".", StringComparison.Ordinal) || + m.StartsWith(phase + ".", StringComparison.Ordinal))) + owners.Add(phase); + } + + var targets = new List(); + foreach (var owner in owners.OrderBy(x => x, StringComparer.Ordinal)) + { + var si = owner + ".units.SIUnit|CF"; + var multiplier = owner + ".units.multiplier|CF"; + // Never publish half of a schema-unverified engineering unit. + if (!unique.TryGetValue(si, out var siAttr) || + !unique.TryGetValue(multiplier, out var mulAttr)) continue; + foreach (var a in new[] { siAttr, mulAttr }) + { + if (ledger.TryGet(a.MmsReference, "CF", out var value) && + !value.IsStale && value.Association == ledger.Association) + continue; + targets.Add(new IedBrowserReadTarget(a.MmsReference, "CF", + string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType)); + if (targets.Count >= budget) return targets; + } + } + return targets; + } +} diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index b0ca02ce6..f225d2ede 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -136,11 +136,23 @@ public static IReadOnlyList BuildFocused( // The Data Model LN/DO read may independently hydrate verified CF. var candidates = Build(liveModel, page, readAll: false, page.Kind == "LN" ? selectedDoReference : null); + // Unit metadata belongs to the canonical Data Model [CF], not to the + // DataSet membership. Hydrate its exact source leaves before drawing + // process magnitudes in DataSet/Report views. + var metadata = page.Kind == "DataSet" + ? IedBrowserMeasurementMetadataReadPlan.BuildForPage( + liveModel, page, ledger, budget) : []; // Do not publish another reader's result as though this association // observed it. Existing Report/MMS Read evidence is always reused. - return candidates.Where(target => - !ledger.TryGet(target.Reference, target.FunctionalConstraint, out _)) - .OrderBy(target => FocusPriority(page.Kind, target)) + return metadata.Concat(candidates) + .Where(target => !ledger.TryGet(target.Reference, + target.FunctionalConstraint, out _)) + .DistinctBy(target => + IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + + target.FunctionalConstraint, StringComparer.Ordinal) + .OrderBy(target => page.Kind == "DataSet" && + target.FunctionalConstraint == "CF" ? -1 : + FocusPriority(page.Kind, target)) .ThenBy(target => target.Reference, StringComparer.Ordinal) .Take(Math.Min(budget, page.Kind == "SGCB" ? 32 : 24)) .ToArray(); diff --git a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs new file mode 100644 index 000000000..e8b7f9c72 --- /dev/null +++ b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs @@ -0,0 +1,170 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217TDataSetReportCanonicalUnitHydrationTests +{ + private const string SetRef = "LD/LLN0.Analog"; + private const string Owner = "LD/MMXU1.PPV"; + + private static LiveIedDataAttributeModel Attribute(string name, string fc, string tail) + { + var obj = "LD/MMXU1." + name; + return new LiveIedDataAttributeModel + { + ObjectReference = obj, + AttributePath = name + "." + tail, + MmsReference = "LD/MMXU1$" + fc + "$" + name + "$" + + tail.Replace('.', '$'), + FunctionalConstraint = fc, + SclBType = tail.EndsWith("SIUnit", StringComparison.Ordinal) + ? "Enum" : "FLOAT32" + }; + } + + private static LiveIedModelDiscoveryDocument Fixture(bool includeUnitPair = true) + { + var ppv = new List(); + foreach (var phase in new[] { "phsAB", "phsBC", "phsCA" }) + { + ppv.Add(Attribute("PPV", "MX", phase + ".cVal.mag.f")); + ppv.Add(Attribute("PPV", "MX", phase + ".cVal.ang.f")); + ppv.Add(Attribute("PPV", "CF", phase + ".units.SIUnit")); + if (includeUnitPair) + ppv.Add(Attribute("PPV", "CF", phase + ".units.multiplier")); + } + return new LiveIedModelDiscoveryDocument + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MMXU1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Name = "PPV", Reference = Owner, + InferredCdc = "DEL", Attributes = ppv + }, + new LiveIedDataObjectModel + { + Name = "PhV", Reference = "LD/MMXU1.PhV", + Attributes = + [ + Attribute("PhV", "MX", "phsA.cVal.mag.f"), + Attribute("PhV", "CF", "phsA.units.SIUnit"), + Attribute("PhV", "CF", "phsA.units.multiplier") + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Domain = "LD", LogicalNode = "LLN0", + Name = "Analog", Reference = SetRef, + Members = + [ + new LiveIedDataSetMemberModel + { + Reference = Owner, FunctionalConstraint = "MX" + } + ] + } + ] + }; + } + + [Fact] + public void OnlyExactLiveDataSetPhaseUnitsAreReadNotUnrelatedSiblings() + { + var model = Fixture(); + var values = new IedBrowserLiveValues(); + var association = values.BeginAssociation(); + var targets = IedBrowserMeasurementMetadataReadPlan.Build( + model, SetRef, values); + Assert.Equal(6, targets.Count); + Assert.All(targets, target => + { + Assert.Equal("CF", target.FunctionalConstraint); + Assert.StartsWith(Owner + ".", IedBrowserLiveValues.CanonicalReference( + target.Reference)); + }); + Assert.DoesNotContain(targets, target => target.Reference.Contains( + "PhV", StringComparison.Ordinal)); + values.PublishForAssociation(association, Owner + + ".phsAB.units.SIUnit", "CF", "5", "", "", "MMS Read"); + var pending = IedBrowserMeasurementMetadataReadPlan.Build( + model, SetRef, values); + Assert.Equal(5, pending.Count); + Assert.DoesNotContain(pending, target => + IedBrowserLiveValues.CanonicalReference(target.Reference) == + Owner + ".phsAB.units.SIUnit"); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.Build( + model, "LD/LLN0.Unknown", values)); + Assert.Equal(2, IedBrowserMeasurementMetadataReadPlan.Build( + model, SetRef, values, budget: 2).Count); + } + + [Fact] + public void FocusedDataSetHydratesCfBeforeMxAndResolvesSharedUnitForReportRows() + { + var model = Fixture(); + var values = new IedBrowserLiveValues(); + var association = values.BeginAssociation(); + var page = new IedBrowserTreeRow( + "datasets/set/" + SetRef, "LLN0.Analog", "DataSet", 3, + model.DataSets.Single()); + var focused = IedBrowserReadPlan.BuildFocused(model, page, values); + Assert.True(focused.Count >= 6); + Assert.All(focused.Take(6), target => + Assert.Equal("CF", target.FunctionalConstraint)); + + foreach (var phase in new[] { "phsAB", "phsBC", "phsCA" }) + { + values.PublishForAssociation(association, + Owner + "." + phase + ".units.SIUnit", + "CF", "6", "", "", "MMS Read"); + values.PublishForAssociation(association, + Owner + "." + phase + ".units.multiplier", + "CF", "3", "", "", "MMS Read"); + values.PublishForAssociation(association, + Owner + "." + phase + ".cVal.mag.f", + "MX", "0", "", "sample-1", "Report"); + values.PublishForAssociation(association, + Owner + "." + phase + ".cVal.ang.f", + "MX", "0", "", "sample-1", "Report"); + } + var cf = new IedBrowserCfEvidencePresentation(values, model, null); + var units = cf.BuildVerifiedUnits([], model); + var dataSetRow = IedBrowserStructuredDetails.Member( + model.DataSets.Single().Members.Single(), model); + Assert.True(IedBrowserEngineeringComposite.TryCompose(dataSetRow, + values, units, true, out var report)); + Assert.Equal("phsAB: 0 kV ∠ 0°; phsBC: 0 kV ∠ 0°; phsCA: 0 kV ∠ 0°", + report.Text); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.Build( + model, SetRef, values)); + } + + [Fact] + public void MissingLiveUnitPairIsNeverReplacedByGuessedSclUnit() + { + var model = Fixture(includeUnitPair: false); + var values = new IedBrowserLiveValues(); + values.BeginAssociation(); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.Build( + model, SetRef, values)); + } +} From 6a6dedaf534c71fcad46f37b4de1736f6c9e9bf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:07:04 +0700 Subject: [PATCH 034/184] fix(browser): import WPF routed event type for scoped RCB unit read --- MainWindow.IedBrowser.RcbFocusedRead.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.IedBrowser.RcbFocusedRead.cs b/MainWindow.IedBrowser.RcbFocusedRead.cs index 5c6e5a093..db79dbc64 100644 --- a/MainWindow.IedBrowser.RcbFocusedRead.cs +++ b/MainWindow.IedBrowser.RcbFocusedRead.cs @@ -1,3 +1,4 @@ +using System.Windows; using System.Windows.Controls; using System.Windows.Threading; using ArIED61850Tester.Models; From 5a959d88762ed572865ce6051f3b05c5e64a67a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:12:03 +0700 Subject: [PATCH 035/184] fix(browser): render verified SG analogue setting magnitudes with canonical CF engineering units --- Models/IedBrowserCfEvidencePresentation.cs | 46 +++++++- .../IedBrowserMeasurementMetadataReadPlan.cs | 58 ++++++++++ Models/IedBrowserReadPlan.cs | 8 +- Models/IedBrowserStructuredDetails.cs | 8 +- .../M217USgAnalogueSettingUnitParityTests.cs | 104 ++++++++++++++++++ 5 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs diff --git a/Models/IedBrowserCfEvidencePresentation.cs b/Models/IedBrowserCfEvidencePresentation.cs index 618259eb7..f823426b5 100644 --- a/Models/IedBrowserCfEvidencePresentation.cs +++ b/Models/IedBrowserCfEvidencePresentation.cs @@ -16,13 +16,15 @@ public sealed class IedBrowserCfEvidencePresentation // Weak keys prevent retained IED schemas/snapshots after reload. These // indexes contain declarations and historical evidence only, never // mutable current Report/Read observations. - private sealed record SchemaIndex(HashSet DeclaredCf, string[] UnitOwners); + private sealed record SchemaIndex( + HashSet DeclaredCf, string[] UnitOwners, string[] SettingOwners); private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< LiveIedModelDiscoveryDocument, SchemaIndex> SchemaCache = new(); private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< LiveIedCanonicalModel, Dictionary> SnapshotCache = new(); private readonly LiveIedModelDiscoveryDocument? _model; private readonly string[] _unitOwners; + private readonly string[] _settingOwners; private readonly Dictionary _snapshot = new(StringComparer.Ordinal); private readonly HashSet _declaredCf = new(StringComparer.Ordinal); public bool HasSnapshot { get; } @@ -38,8 +40,13 @@ public IedBrowserCfEvidencePresentation( var schema = SchemaCache.GetValue(currentModel, BuildSchema); _declaredCf = schema.DeclaredCf; _unitOwners = schema.UnitOwners; + _settingOwners = schema.SettingOwners; + } + else + { + _unitOwners = []; + _settingOwners = []; } - else _unitOwners = []; if (canonical is not null && currentModel is not null && ReferenceEquals(currentModel, canonical.Discovery)) { @@ -52,6 +59,7 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) { var declared = new HashSet(StringComparer.Ordinal); var owners = new HashSet(StringComparer.Ordinal); + var settingOwners = new HashSet(StringComparer.Ordinal); foreach (var a in model.LogicalDevices.SelectMany(d => d.LogicalNodes) .SelectMany(n => n.DataObjects).SelectMany(o => o.Attributes)) { @@ -60,6 +68,19 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) if (string.Equals(a.FunctionalConstraint, "CF", StringComparison.OrdinalIgnoreCase)) declared.Add(Key(reference, "CF")); + if (string.Equals(a.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase)) + { + // IEC ASG analogue setMag shares the same observed CF unit + // owner. Do not treat enum setVal or a vendor string as a + // measurement and do not infer units from DO names. + var settingSuffix = reference.EndsWith(".setMag.f", + StringComparison.Ordinal) ? ".setMag.f" : + reference.EndsWith(".setMag", StringComparison.Ordinal) + ? ".setMag" : ""; + if (settingSuffix.Length > 0) + settingOwners.Add(reference[..^settingSuffix.Length]); + } if (!string.Equals(a.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase)) continue; var suffix = reference.EndsWith(".cVal.mag.f", StringComparison.Ordinal) @@ -68,7 +89,7 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) if (suffix.Length != 0) owners.Add(reference[..^suffix.Length]); } return new SchemaIndex(declared, owners.OrderBy(s => s, StringComparer.Ordinal) - .ToArray()); + .ToArray(), settingOwners.OrderBy(s => s, StringComparer.Ordinal).ToArray()); } private static Dictionary BuildSnapshot(LiveIedCanonicalModel canonical) @@ -241,6 +262,25 @@ public static string RenderCfValue(string reference, string fc, string raw) index[exact] = composed; } } + // IEC ASG/SG setting magnitude: CF units belong to its exact DO, + // never to a neighboring setting or to ordinal enum setVal values. + foreach (var owner in _settingOwners) + { + if (!TryVerifyUnitPair(owner + ".units", out var composed, + out _, out var fresh)) continue; + var key = Key(owner, "SG"); + if (!index.TryGetValue(key, out var previous)) + index.Add(key, composed); + else if (!string.Equals(previous, composed, StringComparison.Ordinal)) + index[key] = fresh ? composed : null; + if (fresh) index[key] = composed; + if (fresh) + foreach (var leaf in new[] { ".setMag", ".setMag.f" }) + { + var exact = owner + leaf + "|SG"; + if (index.ContainsKey(exact)) index[exact] = composed; + } + } return index; } diff --git a/Models/IedBrowserMeasurementMetadataReadPlan.cs b/Models/IedBrowserMeasurementMetadataReadPlan.cs index 27b9e734d..0a565c916 100644 --- a/Models/IedBrowserMeasurementMetadataReadPlan.cs +++ b/Models/IedBrowserMeasurementMetadataReadPlan.cs @@ -22,6 +22,64 @@ public static IReadOnlyList BuildForPage( return Build(model, reference, ledger, budget); } + /// + /// Same SSOT CF acquisition for active SG analogue magnitudes. Only + /// a live SGCB's exact MMS domain and IEC ASG setMag declarations qualify. + /// Ordinal setVal enums are never rewritten as physical magnitudes. + /// + public static IReadOnlyList BuildForSettingGroup( + LiveIedModelDiscoveryDocument? model, IedBrowserTreeRow? page, + IedBrowserLiveValues ledger, int budget = 12) + { + if (model is null || ledger.IsOffline || budget <= 0 || + page is not { Kind: "SGCB", Payload: LiveIedControlBlockModel block }) + return []; + + var attributes = model.LogicalDevices + .Where(ld => string.Equals(ld.MmsDomain, block.Domain, + StringComparison.Ordinal)) + .SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .SelectMany(obj => obj.Attributes) + .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)) + .ToArray(); + var unique = attributes.GroupBy(a => + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + "|" + + (a.FunctionalConstraint ?? "").Trim().ToUpperInvariant(), + StringComparer.Ordinal) + .Where(g => g.Count() == 1) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + var owners = attributes.Where(a => + string.Equals(a.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase)) + .Select(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference)) + .Select(reference => reference.EndsWith(".setMag.f", + StringComparison.Ordinal) ? reference[..^".setMag.f".Length] : + reference.EndsWith(".setMag", StringComparison.Ordinal) + ? reference[..^".setMag".Length] : "") + .Where(x => x.Length > 0).Distinct(StringComparer.Ordinal) + .OrderBy(x => x, StringComparer.Ordinal); + var result = new List(); + foreach (var owner in owners) + { + if (!unique.TryGetValue(owner + ".units.SIUnit|CF", out var si) || + !unique.TryGetValue(owner + ".units.multiplier|CF", + out var multiplier)) continue; + foreach (var attr in new[] { si, multiplier }) + { + if (ledger.TryGet(attr.MmsReference, "CF", out var observed) && + !observed.IsStale && observed.Association == ledger.Association) + continue; + result.Add(new IedBrowserReadTarget(attr.MmsReference, "CF", + string.IsNullOrWhiteSpace(attr.SclBType) + ? attr.MmsType : attr.SclBType)); + if (result.Count >= budget) return result; + } + } + return result; + } + public static IReadOnlyList Build( LiveIedModelDiscoveryDocument? model, string? dataSetReference, IedBrowserLiveValues ledger, int budget = 24) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index f225d2ede..257b79587 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -141,7 +141,11 @@ public static IReadOnlyList BuildFocused( // process magnitudes in DataSet/Report views. var metadata = page.Kind == "DataSet" ? IedBrowserMeasurementMetadataReadPlan.BuildForPage( - liveModel, page, ledger, budget) : []; + liveModel, page, ledger, budget) + : page.Kind == "SGCB" + ? IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + liveModel, page, ledger, budget) + : []; // Do not publish another reader's result as though this association // observed it. Existing Report/MMS Read evidence is always reused. return metadata.Concat(candidates) @@ -150,7 +154,7 @@ public static IReadOnlyList BuildFocused( .DistinctBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, StringComparer.Ordinal) - .OrderBy(target => page.Kind == "DataSet" && + .OrderBy(target => (page.Kind is "DataSet" or "SGCB") && target.FunctionalConstraint == "CF" ? -1 : FocusPriority(page.Kind, target)) .ThenBy(target => target.Reference, StringComparer.Ordinal) diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index 81280265a..bca5d8866 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -407,8 +407,12 @@ private static IReadOnlyList BuildAt( : scalarLeaf ?? (attr.ObjectReference + "." + string.Join(".", groupArray[0].parts.Take(pathIndex + 1))), FallbackValueReference = hasDirect ? scalarLeaf ?? "" : "", - EngineeringUnitReference = scalarLeaf ?? "", - IsEngineeringValue = scalarLeaf is not null && descendants.Length > 0, + EngineeringUnitReference = scalarLeaf ?? + (name == "setMag" ? fullPrefix : ""), + IsEngineeringValue = scalarLeaf is not null && descendants.Length > 0 || + name == "setMag" && hasDirect && + string.Equals(attr.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase), PolarPlan = polar, PhasePlans = phases, VectorComponents = !allowVector || name is "q" or "t" ? diff --git a/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs b/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs new file mode 100644 index 000000000..30ddaae81 --- /dev/null +++ b/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs @@ -0,0 +1,104 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217USgAnalogueSettingUnitParityTests +{ + private const string Owner = "LD/SGgndPIOC1.StrVal"; + private static LiveIedDataAttributeModel Attribute(string path, string fc) + => new() + { + ObjectReference = Owner, + AttributePath = "StrVal." + path, + MmsReference = "LD/SGgndPIOC1$" + fc + "$StrVal$" + + path.Replace('.', '$'), + FunctionalConstraint = fc, + SclBType = "FLOAT32" + }; + + private static (LiveIedModelDiscoveryDocument Model, IedBrowserTreeRow Page) + ModelAndPage() + { + var block = new LiveIedControlBlockModel + { + Domain = "LD" + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "SGgndPIOC1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Name = "StrVal", Reference = Owner, + Attributes = + [ + Attribute("setMag.f", "SG"), + Attribute("units.SIUnit", "CF"), + Attribute("units.multiplier", "CF"), + Attribute("setVal", "SE") + ] + } + ] + } + ] + } + ] + }; + return (model, new IedBrowserTreeRow( + "sg/LD/LLN0.SGCB", "SGCB", "SGCB", 3, block)); + } + + [Fact] + public void ActualSettingMagnitudeUsesVerifiedCfUnitNotVendorGuess() + { + var (model, page) = ModelAndPage(); + var values = new IedBrowserLiveValues(); + var association = values.BeginAssociation(); + var candidates = IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + model, page, values); + Assert.Equal(2, candidates.Count); + Assert.All(candidates, t => Assert.Equal("CF", t.FunctionalConstraint)); + values.PublishForAssociation(association, Owner + ".setMag.f", + "SG", "0", "", "", "MMS Read"); + values.PublishForAssociation(association, Owner + ".units.SIUnit", + "CF", "5", "", "", "MMS Read"); + values.PublishForAssociation(association, Owner + ".units.multiplier", + "CF", "3", "", "", "MMS Read"); + var cf = new IedBrowserCfEvidencePresentation(values, model, null); + var units = cf.BuildVerifiedUnits([], model); + Assert.Equal("kA", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, Owner + ".setMag.f", "SG")); + Assert.Equal("0 kA", IedBrowserEngineeringValuePresentation.Format("0", + IedBrowserEngineeringValuePresentation.ResolveUnit( + units, Owner + ".setMag.f", "SG"))); + Assert.Equal("", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, Owner + ".setVal", "SE")); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + model, page, values)); + } + + [Fact] + public void OnlyExactSgDomainAndSchemaCfPairsMayAuthorizeMetadataRead() + { + var (model, page) = ModelAndPage(); + var values = new IedBrowserLiveValues(); + values.BeginAssociation(); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + model, new IedBrowserTreeRow("other", "other", "DataSet", 2), values)); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + null, page, values)); + Assert.Equal(1, IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( + model, page, values, budget: 1).Count); + } +} From 6b7b78d62172030878ccd2f2ec00d9577ff88690 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:13:41 +0700 Subject: [PATCH 036/184] test(browser): use IEC verified V symbol rather than Kelvin ordinal in report parity fixture --- .../M217TDataSetReportCanonicalUnitHydrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs index e8b7f9c72..af02c5536 100644 --- a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs +++ b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs @@ -135,7 +135,7 @@ public void FocusedDataSetHydratesCfBeforeMxAndResolvesSharedUnitForReportRows() { values.PublishForAssociation(association, Owner + "." + phase + ".units.SIUnit", - "CF", "6", "", "", "MMS Read"); + "CF", "V", "", "", "MMS Read"); values.PublishForAssociation(association, Owner + "." + phase + ".units.multiplier", "CF", "3", "", "", "MMS Read"); From 6bd1a517f3c426714c1151bc4f3ff4abf092c56a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:17:03 +0700 Subject: [PATCH 037/184] fix(browser): hydrate IEC MV scalar power and frequency units beside three-phase vectors --- .../IedBrowserMeasurementMetadataReadPlan.cs | 11 +++- ...ataSetReportCanonicalUnitHydrationTests.cs | 66 ++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Models/IedBrowserMeasurementMetadataReadPlan.cs b/Models/IedBrowserMeasurementMetadataReadPlan.cs index 0a565c916..76508a0ec 100644 --- a/Models/IedBrowserMeasurementMetadataReadPlan.cs +++ b/Models/IedBrowserMeasurementMetadataReadPlan.cs @@ -116,8 +116,15 @@ public static IReadOnlyList Build( if (!string.Equals(attr.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase)) continue; var reference = IedBrowserLiveValues.CanonicalReference(attr.MmsReference); - const string suffix = ".cVal.mag.f"; - if (!reference.EndsWith(suffix, StringComparison.Ordinal)) continue; + // IEC WYE/DEL vectors and ordinary MV measured scalars share + // the same CF Unit contract, but have different mag paths. + // Instantaneous variants never authorize another owner's unit. + var suffix = reference.EndsWith(".cVal.mag.f", + StringComparison.Ordinal) ? ".cVal.mag.f" : + reference.EndsWith(".mag.f", StringComparison.Ordinal) && + !reference.EndsWith(".instCVal.mag.f", StringComparison.Ordinal) + ? ".mag.f" : ""; + if (suffix.Length == 0) continue; var phase = reference[..^suffix.Length]; // Member scope includes exact FCD and FCDA subobjects, but not // other logical nodes, siblings, or similarly prefixed objects. diff --git a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs index af02c5536..40678b902 100644 --- a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs +++ b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs @@ -135,7 +135,7 @@ public void FocusedDataSetHydratesCfBeforeMxAndResolvesSharedUnitForReportRows() { values.PublishForAssociation(association, Owner + "." + phase + ".units.SIUnit", - "CF", "V", "", "", "MMS Read"); + "CF", "29", "", "", "MMS Read"); values.PublishForAssociation(association, Owner + "." + phase + ".units.multiplier", "CF", "3", "", "", "MMS Read"); @@ -158,6 +158,70 @@ public void FocusedDataSetHydratesCfBeforeMxAndResolvesSharedUnitForReportRows() model, SetRef, values)); } + [Fact] + public void ScalarMvMagnitudeHydratesExactTotvaUnitForReportWithoutTouchingSibling() + { + var target = "LD/MMXU1.TotVA"; + var other = "LD/MMXU1.TotW"; + var objects = new[] { target, other }.Select(owner => + { + var name = owner[(owner.LastIndexOf('.') + 1)..]; + return new LiveIedDataObjectModel + { + Name = name, Reference = owner, InferredCdc = "MV", + Attributes = + [ + Attribute(name, "MX", "mag.f"), + Attribute(name, "CF", "units.SIUnit"), + Attribute(name, "CF", "units.multiplier") + ] + }; + }).ToArray(); + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MMXU1", DataObjects = objects + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Domain = "LD", LogicalNode = "LLN0", + Name = "Power", Reference = "LD/LLN0.Power", + Members = + [ + new LiveIedDataSetMemberModel + { + Reference = target, FunctionalConstraint = "MX" + } + ] + } + ] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var targets = IedBrowserMeasurementMetadataReadPlan.Build( + model, "LD/LLN0.Power", ledger); + Assert.Equal(2, targets.Count); + Assert.All(targets, attr => + Assert.StartsWith(target + ".", + IedBrowserLiveValues.CanonicalReference(attr.Reference))); + Assert.DoesNotContain(targets, attr => + IedBrowserLiveValues.CanonicalReference(attr.Reference) + .StartsWith(other + ".", StringComparison.Ordinal)); + } + [Fact] public void MissingLiveUnitPairIsNeverReplacedByGuessedSclUnit() { From 7e75064474fa2b5b30da26c54e2d4e83859bf741 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:18:41 +0700 Subject: [PATCH 038/184] test(browser): prove RCB and DataSet use identical canonical CF unit read scope --- ...ataSetReportCanonicalUnitHydrationTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs index 40678b902..110d12e96 100644 --- a/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs +++ b/tests/ARSAS.Tests/M217TDataSetReportCanonicalUnitHydrationTests.cs @@ -158,6 +158,38 @@ public void FocusedDataSetHydratesCfBeforeMxAndResolvesSharedUnitForReportRows() model, SetRef, values)); } + [Fact] + public void ReportRcbSelectsSameCanonicalMetadataTargetsAsItsDataSet() + { + var model = Fixture(); + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var set = new IedBrowserTreeRow( + "datasets/set/" + SetRef, "LLN0.Analog", "DataSet", 3, + model.DataSets.Single()); + var report = new IedBrowserTreeRow( + "reports/rcb/test", "URB_MX", "RCB", 3, + new RcbExportRow + { + Reference = "LD/LLN0.RP.URB_MX", + DataSetReference = SetRef + }); + var setCf = IedBrowserMeasurementMetadataReadPlan.BuildForPage( + model, set, ledger); + var reportCf = IedBrowserMeasurementMetadataReadPlan.BuildForPage( + model, report, ledger); + Assert.Equal(setCf, reportCf); + Assert.Equal(6, reportCf.Count); + var unknown = new IedBrowserTreeRow( + "reports/rcb/unknown", "unknown", "RCB", 3, + new RcbExportRow + { + DataSetReference = "LD/LLN0.NotInLiveModel" + }); + Assert.Empty(IedBrowserMeasurementMetadataReadPlan.BuildForPage( + model, unknown, ledger)); + } + [Fact] public void ScalarMvMagnitudeHydratesExactTotvaUnitForReportWithoutTouchingSibling() { From c9a6b9ff41d3fc37259ea76718fef7825c68fdca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:47:51 +0700 Subject: [PATCH 039/184] fix(browser): retain observed polar angles in shared SSOT-based report and dataset projections without false pairing --- Models/IedBrowserEngineeringComposite.cs | 10 +++++++--- Models/IedBrowserEngineeringValuePresentation.cs | 7 ++++--- Models/IedBrowserObservedVectorPresentation.cs | 2 +- .../IedBrowserCrossThreadVectorParityTests.cs | 6 +++--- .../ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs | 2 +- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index 3766b6435..d2d14b782 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -181,10 +181,14 @@ public static bool TryCompose( // provenance tooltip, never discarded from the SSOT ledger. var independentUnit = IedBrowserEngineeringValuePresentation.ResolveVectorAngleUnit( unitIndex, plan.AngleReference, plan.FunctionalConstraint); + var angleText = IedBrowserEngineeringValuePresentation.Format( + independentAngle.Value, independentUnit); + // This is a visible angle observation, NOT a paired phasor. + // Show it without inventing sample coherence. The leaf and + // provenance still preserve the original MMS source/time. + text += " · ang " + angleText; references.Add(plan.AngleReference + "[separate " + - plan.FunctionalConstraint + "]: " + - IedBrowserEngineeringValuePresentation.Format( - independentAngle.Value, independentUnit)); + plan.FunctionalConstraint + "]: " + angleText); } components.Add(plan.Phase.Length == 0 ? text : plan.Phase + ": " + text); } diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index 993a10ed7..d7d2ffd13 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -64,14 +64,15 @@ public static string ResolveVectorAngleUnit( if (!canonical.EndsWith(".ang.f", StringComparison.Ordinal) || normalizedFc != "MX") return ""; if (index.TryGetValue(canonical + "|" + normalizedFc, out var exact)) - return exact ?? ""; + return exact is "deg" or "degree" or "degrees" ? "°" : exact ?? ""; var angleOwner = canonical[..^2]; if (index.TryGetValue(angleOwner + "|" + normalizedFc, out var declared)) - return declared ?? ""; + return declared is "deg" or "degree" or "degrees" ? "°" : declared ?? ""; // IEC 61850-7-3 Vector.ang: Unit.SIUnit='deg', no multiplier. // Exact cVal/instCVal paths only, never inferred for scalar MV. return canonical.EndsWith(".cVal.ang.f", StringComparison.Ordinal) || - canonical.EndsWith(".instCVal.ang.f", StringComparison.Ordinal) + canonical.EndsWith(".instCVal.ang.f", StringComparison.Ordinal) || + canonical.EndsWith(".ang.f", StringComparison.Ordinal) ? "°" : ""; } diff --git a/Models/IedBrowserObservedVectorPresentation.cs b/Models/IedBrowserObservedVectorPresentation.cs index 598752eee..2efd6d93f 100644 --- a/Models/IedBrowserObservedVectorPresentation.cs +++ b/Models/IedBrowserObservedVectorPresentation.cs @@ -71,7 +71,7 @@ public static bool TryFormat( // Clean operator Value: when no coherent pair exists, // show a magnitude alone (or just ° if only ang exists). // Never append a pseudo phasor or "independent" jargon. - if (!hasMag) text = rendered; + text += (hasMag ? " · ang " : "") + rendered; unpaired.Add(part.Label + ": " + (hasMag ? "angle not paired with magnitude" : "magnitude not observed") + diff --git a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs index 6ee9e52c8..9bd69a936 100644 --- a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs +++ b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs @@ -35,7 +35,7 @@ public void DifferentTimestampNeverRendersFalsePairedPhasor() "33", "", "t2", "Report")); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 12 kV", rendered.Value); + Assert.Equal("phsC: 12 kV · ang 33°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.DoesNotContain(" kV ∠ ", rendered.Value); Assert.Contains(Mag, rendered.Provenance); @@ -53,7 +53,7 @@ public void MixedReportAndMmsReadPreserveBothValuesButNeverPair() "40", "", "t1", "MMS Read"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 0 kV", rendered.Value); + Assert.Equal("phsC: 0 kV · ang 40°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.Equal("Report + MMS Read", rendered.Source); Assert.DoesNotContain(" kV ∠ ", rendered.Value); @@ -113,7 +113,7 @@ public void FourModelVerifiedComponentsIncludeNeutralButRespectPerPairCompatibil Assert.Contains("phsA: 12 ∠ 17", result.Value); Assert.Contains("phsB: 12 ∠ 17", result.Value); Assert.Contains("phsC: 12 ∠ 17", result.Value); - Assert.Contains("neut: 12", result.Value); + Assert.Contains("neut: 12 · ang 17", result.Value); Assert.Contains("neut: angle not paired with magnitude", result.Provenance); Assert.DoesNotContain("neut: 12 ∠", result.Value); Assert.DoesNotContain("independent", result.Value, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs index f36762eee..a06a80f92 100644 --- a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs +++ b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs @@ -118,7 +118,7 @@ public void WrongFcAndConflictingAngleTimestampFailClosed() phase.AngleReference, "MX", "33", "", "timestamp2", "Report"); Assert.True(IedBrowserEngineeringComposite.TryCompose( row, ledger, new Dictionary(), true, out var output)); - Assert.Equal("12", output.Text); // angle remains accessible on its child + Assert.Equal("12 · ang 33°", output.Text); // angle remains accessible on its child Assert.DoesNotContain("∠", output.Text); // never a falsely paired phasor Assert.Contains("33°", output.Provenance); } From 573a09530c6d6469b1566604226aa0da5f7de1f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:51:07 +0700 Subject: [PATCH 040/184] fix(browser): expose observed three-phase angles on compact MMXU root summaries too --- Models/IedBrowserEngineeringComposite.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index d2d14b782..f76f0fef8 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -168,7 +168,7 @@ public static bool TryCompose( references.Add(plan.AngleReference + "[" + plan.FunctionalConstraint + "]"); anglePaired = true; } - if (!anglePaired && !compactDo && plan.AngleReference.Length > 0 && + if (!anglePaired && plan.AngleReference.Length > 0 && ledger.TryGet(plan.AngleReference, plan.FunctionalConstraint, out var independentAngle, preferReport: monitoring) && double.TryParse(independentAngle.Value, NumberStyles.Float, From 1137483488f09a64c04e8474ed6ef2fe223eb594 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:52:39 +0700 Subject: [PATCH 041/184] test(browser): assert model and report dataset show identical verified engineering phasors --- .../IedBrowserEngineeringCompositeTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs index a06a80f92..be2aa0bbd 100644 --- a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs +++ b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs @@ -289,4 +289,44 @@ public void CompleteVectorShapeStillRendersDeclaredAngle() Assert.Equal(3, root.PhasePlans.Count); } + [Fact] + public void ModelAndReportDataSetShareTheSameUnitsAndSafePolarProjection() + { + var obj = Object(); + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + LogicalNodes = [new LiveIedLogicalNodeModel + { + DataObjects = [obj] + }] + }] + }; + var member = new LiveIedDataSetMemberModel + { + Reference = Owner, FunctionalConstraint = "MX" + }; + var dataModel = IedBrowserStructuredDetails.DataObject(obj, "MX"); + var reportOrDataSet = IedBrowserStructuredDetails.Member(member, model); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var units = new Dictionary(StringComparer.Ordinal); + foreach (var phase in dataModel.PhasePlans) + { + ledger.PublishForAssociation(association, phase.MagnitudeReference, + "MX", "0", "", "same-wire-time", "Report"); + ledger.PublishForAssociation(association, phase.AngleReference, + "MX", "0", "", "same-wire-time", "Report"); + units[phase.MagnitudeReference + "|MX"] = "kV"; + } + Assert.True(IedBrowserEngineeringComposite.TryCompose( + dataModel, ledger, units, true, out var modelValue)); + Assert.True(IedBrowserEngineeringComposite.TryCompose( + reportOrDataSet, ledger, units, true, out var reportValue)); + Assert.Equal(modelValue.Text, reportValue.Text); + Assert.Equal("phsA: 0 kV ∠ 0°; phsB: 0 kV ∠ 0°; phsC: 0 kV ∠ 0°", + reportValue.Text); + } + } From 74396f7b144aca1b90abe18c8cb73d2c44cab3aa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 06:58:42 +0700 Subject: [PATCH 042/184] fix(browser): keep degree fallback limited to proven IEC cVal vectors --- Models/IedBrowserEngineeringValuePresentation.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index d7d2ffd13..7f9261242 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -71,8 +71,7 @@ public static string ResolveVectorAngleUnit( // IEC 61850-7-3 Vector.ang: Unit.SIUnit='deg', no multiplier. // Exact cVal/instCVal paths only, never inferred for scalar MV. return canonical.EndsWith(".cVal.ang.f", StringComparison.Ordinal) || - canonical.EndsWith(".instCVal.ang.f", StringComparison.Ordinal) || - canonical.EndsWith(".ang.f", StringComparison.Ordinal) + canonical.EndsWith(".instCVal.ang.f", StringComparison.Ordinal) ? "°" : ""; } From 66c6866f6807d25a0f58b36c837211a5bdff9c1d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 07:26:11 +0700 Subject: [PATCH 043/184] fix(browser): restore Report CF read dispatch, prioritize SG settings over metadata, and IEC angle display --- MainWindow.IedBrowser.cs | 24 +++++++++++++++---- Models/IedBrowserEngineeringComposite.cs | 2 +- .../IedBrowserObservedVectorPresentation.cs | 5 +++- Models/IedBrowserReadPlan.cs | 8 ++++++- .../IedBrowserCrossThreadVectorParityTests.cs | 12 +++++----- .../IedBrowserEngineeringCompositeTests.cs | 4 ++-- .../M214MergedCfAndVectorAuthorityTests.cs | 4 ++-- .../M217USgAnalogueSettingUnitParityTests.cs | 17 +++++++++++++ 8 files changed, 58 insertions(+), 18 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 392fdf0ee..da4b9965f 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -277,9 +277,14 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // Its bounded reads never interfere with the reporting session. var configRequest = (sender as Button)?.Tag as IedBrowserVisibleConfigurationReadRequest; - var automatic = sender is Button { Tag: "Auto" } || configRequest is not null; + // A selected Report's CF unit hydration is a READ OF THE REFERENCED + // DATASET METADATA, not an RCB Inspect action. It must enter the + // ordinary typed Read → association-fenced LiveValues pipeline. + var reportUnitRequest = sender is Button { Tag: "ReportUnitAuto" }; + var automatic = sender is Button { Tag: "Auto" } || + configRequest is not null || reportUnitRequest; var readAll = sender is Button { Tag: "All" }; - if (!readAll) + if (!readAll && !reportUnitRequest) { switch (IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current)) { @@ -307,7 +312,14 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) model, currentPage, readAll, selectedDo); if (automatic) { - if (configRequest is not null) + if (reportUnitRequest) + { + if (currentPage is not { Kind: "RCB", Payload: RcbExportRow }) + return; + targets = IedBrowserMeasurementMetadataReadPlan.BuildForPage( + device.LiveDiscoveryModel, currentPage, device.LiveValues); + } + else if (configRequest is not null) { // Expanded phase/Nameplate metadata: exact missing CF/DC leaves // of the current verified LN. A stale/collapsed row may not read. @@ -344,12 +356,14 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // Rejected/busy/cancelled preflight cannot poison a future visit. var automaticKey = automatic && currentPage is not null ? IedBrowserFocusedAdmission.PageKey( - currentPage.Key, configRequest?.Scope ?? selectedDo) + currentPage.Key, reportUnitRequest ? "|unit-CF" : + configRequest?.Scope ?? selectedDo) : null; if (automaticKey is not null && !_iedBrowserAutoReadAdmission.TryStart(device.DeviceId, device.LiveValues.Association, automaticKey, configRequest is not null ? 1 : + reportUnitRequest ? 3 : currentPage!.Kind == "SGCB" ? 3 : currentPage.Kind is "LN" or "DO" ? 2 : 1)) return; @@ -532,7 +546,7 @@ _iedBrowserNavigation.Current is { } currentFocus && _iedBrowserSelectedDoReference); QueueIedBrowserRcbFocusedRead(currentFocus); } - if (configRequest is null && + if (configRequest is null && !reportUnitRequest && ReferenceEquals(SelectedDevice, sourceDevice) && _iedBrowserNavigation.Current is { Kind: "LN" } && _iedBrowserReadOperation is null) diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index f76f0fef8..90c19fe2f 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -186,7 +186,7 @@ public static bool TryCompose( // This is a visible angle observation, NOT a paired phasor. // Show it without inventing sample coherence. The leaf and // provenance still preserve the original MMS source/time. - text += " · ang " + angleText; + text += " ∠ " + angleText; references.Add(plan.AngleReference + "[separate " + plan.FunctionalConstraint + "]: " + angleText); } diff --git a/Models/IedBrowserObservedVectorPresentation.cs b/Models/IedBrowserObservedVectorPresentation.cs index 2efd6d93f..2dad76190 100644 --- a/Models/IedBrowserObservedVectorPresentation.cs +++ b/Models/IedBrowserObservedVectorPresentation.cs @@ -71,7 +71,10 @@ public static bool TryFormat( // Clean operator Value: when no coherent pair exists, // show a magnitude alone (or just ° if only ang exists). // Never append a pseudo phasor or "independent" jargon. - text += (hasMag ? " · ang " : "") + rendered; + // The glyph is the IEC engineering angle notation, + // NOT evidence that two independent updates share a + // timestamp. That distinction remains in provenance. + text += (hasMag ? " ∠ " : "") + rendered; unpaired.Add(part.Label + ": " + (hasMag ? "angle not paired with magnitude" : "magnitude not observed") + diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 257b79587..0c2c7f3b4 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -154,7 +154,13 @@ public static IReadOnlyList BuildFocused( .DistinctBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, StringComparer.Ordinal) - .OrderBy(target => (page.Kind is "DataSet" or "SGCB") && + // Read the active SG values FIRST. Previously CF metadata + // was ahead of SG in the same 32-target / 15-second budget: + // slow/unavailable CF could exhaust the entire read before any + // setting arrived. Units remain a later bounded best-effort read. + .OrderBy(target => page.Kind == "SGCB" && + target.FunctionalConstraint == "SG" ? -2 : + page.Kind == "DataSet" && target.FunctionalConstraint == "CF" ? -1 : FocusPriority(page.Kind, target)) .ThenBy(target => target.Reference, StringComparer.Ordinal) diff --git a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs index 9bd69a936..b3d93af74 100644 --- a/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs +++ b/tests/ARSAS.Tests/IedBrowserCrossThreadVectorParityTests.cs @@ -35,9 +35,9 @@ public void DifferentTimestampNeverRendersFalsePairedPhasor() "33", "", "t2", "Report")); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 12 kV · ang 33°", rendered.Value); + Assert.Equal("phsC: 12 kV ∠ 33°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); - Assert.DoesNotContain(" kV ∠ ", rendered.Value); + Assert.Contains("not paired", rendered.Provenance); Assert.Contains(Mag, rendered.Provenance); Assert.Contains(Ang, rendered.Provenance); } @@ -53,10 +53,10 @@ public void MixedReportAndMmsReadPreserveBothValuesButNeverPair() "40", "", "t1", "MMS Read"); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( values, [Component()], Units(), true, out var rendered)); - Assert.Equal("phsC: 0 kV · ang 40°", rendered.Value); + Assert.Equal("phsC: 0 kV ∠ 40°", rendered.Value); Assert.Contains("not paired with magnitude", rendered.Provenance); Assert.Equal("Report + MMS Read", rendered.Source); - Assert.DoesNotContain(" kV ∠ ", rendered.Value); + Assert.Contains("not paired", rendered.Provenance); } [Fact] @@ -113,9 +113,9 @@ public void FourModelVerifiedComponentsIncludeNeutralButRespectPerPairCompatibil Assert.Contains("phsA: 12 ∠ 17", result.Value); Assert.Contains("phsB: 12 ∠ 17", result.Value); Assert.Contains("phsC: 12 ∠ 17", result.Value); - Assert.Contains("neut: 12 · ang 17", result.Value); + Assert.Contains("neut: 12 ∠ 17", result.Value); Assert.Contains("neut: angle not paired with magnitude", result.Provenance); - Assert.DoesNotContain("neut: 12 ∠", result.Value); + Assert.Contains("not paired", result.Provenance); Assert.DoesNotContain("independent", result.Value, StringComparison.OrdinalIgnoreCase); values.MarkOffline(); Assert.True(IedBrowserObservedVectorPresentation.TryFormat( diff --git a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs index be2aa0bbd..a7ccd02e7 100644 --- a/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs +++ b/tests/ARSAS.Tests/IedBrowserEngineeringCompositeTests.cs @@ -118,8 +118,8 @@ public void WrongFcAndConflictingAngleTimestampFailClosed() phase.AngleReference, "MX", "33", "", "timestamp2", "Report"); Assert.True(IedBrowserEngineeringComposite.TryCompose( row, ledger, new Dictionary(), true, out var output)); - Assert.Equal("12 · ang 33°", output.Text); // angle remains accessible on its child - Assert.DoesNotContain("∠", output.Text); // never a falsely paired phasor + Assert.Equal("12 ∠ 33°", output.Text); // angle remains accessible on its child + Assert.Contains("separate", output.Provenance); // glyph does not imply correlated timestamps Assert.Contains("33°", output.Provenance); } diff --git a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs index 9c8339993..e4f8c64ce 100644 --- a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs +++ b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs @@ -46,7 +46,7 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() Assert.Contains("phsA: 10", unpaired.Text); Assert.DoesNotContain("angle 30", unpaired.Text); Assert.DoesNotContain("neut: 3", unpaired.Text); - Assert.DoesNotContain("∠ 30", unpaired.Text); + Assert.Contains("∠ 30", unpaired.Text); Assert.Contains("other model-proven components on expansion", unpaired.Provenance); // The complete independently observed vector remains accessible @@ -55,7 +55,7 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() Assert.True(IedBrowserEngineeringComposite.TryCompose( expanded, ledger, new Dictionary(), true, out var all)); Assert.Equal("Report", all.Source); - Assert.DoesNotContain("∠", all.Text); + Assert.Contains("∠", all.Text); Assert.Contains("angle 30", all.Provenance); Assert.DoesNotContain("independent, not paired", all.Text); Assert.Contains("not paired with magnitude", all.Provenance); diff --git a/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs b/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs index 30ddaae81..40295a441 100644 --- a/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs +++ b/tests/ARSAS.Tests/M217USgAnalogueSettingUnitParityTests.cs @@ -101,4 +101,21 @@ public void OnlyExactSgDomainAndSchemaCfPairsMayAuthorizeMetadataRead() Assert.Equal(1, IedBrowserMeasurementMetadataReadPlan.BuildForSettingGroup( model, page, values, budget: 1).Count); } + [Fact] + public void AutoSettingReadMustReadSgBeforeSlowCfMetadata() + { + var (model, page) = ModelAndPage(); + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var plan = IedBrowserReadPlan.BuildFocused(model, page, ledger, budget: 12); + Assert.NotEmpty(plan); + Assert.Equal("SG", plan[0].FunctionalConstraint); + Assert.EndsWith(".setMag.f", + IedBrowserLiveValues.CanonicalReference(plan[0].Reference)); + Assert.Contains(plan, t => t.FunctionalConstraint == "CF"); + Assert.DoesNotContain(plan, t => t.FunctionalConstraint == "SE"); + Assert.All(plan, t => Assert.StartsWith("LD/", + IedBrowserLiveValues.CanonicalReference(t.Reference))); + } + } From 78e533e1aa88742fe445e755938eb710d815ba36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:26:39 +0700 Subject: [PATCH 044/184] test(P0): reproduce parent-angle unit leakage, PhV CF owner, and XCBR enum decoding --- .../P0IedScoutFieldSemanticParityTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs diff --git a/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs b/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs new file mode 100644 index 000000000..7d7995a10 --- /dev/null +++ b/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs @@ -0,0 +1,115 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// Field P0 reproductions against exact IEC owner/FC; never fake IED observations. +public sealed class P0IedScoutFieldSemanticParityTests +{ + private static readonly string Phase = "LD/MMXU1.PPV.phsAB"; + + [Theory] + [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang")] + [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang.f")] + [InlineData("LD/MMXU1.PhV.phsA.cVal.ang")] + [InlineData("LD/MMXU1.A.phsA.cVal.ang")] + public void Ang01_Ang02_VectorAngleParentsAndLeavesCannotInheritMagnitudeUnits(string path) + { + var owner = path[..path.IndexOf(".cVal.ang", StringComparison.Ordinal)]; + var unitIndex = new Dictionary + { + [owner + "|MX"] = owner.Contains(".A.", StringComparison.Ordinal) ? "kA" : "kV" + }; + var role = IedBrowserEngineeringValuePresentation.ResolveUnit(unitIndex, path, "MX"); + Assert.Equal("°", role); + Assert.Equal("0°", IedBrowserEngineeringValuePresentation.Format("0", role)); + Assert.Equal(unitIndex[owner + "|MX"], + IedBrowserEngineeringValuePresentation.ResolveUnit( + unitIndex, owner + ".cVal.mag.f", "MX")); + } + + [Fact] + public void Phv01_ExactLiveCfPairWinsOverConflictingSignalMagnitudeMetadata() + { + var owner = "LD/MMXU1.PhV.phsA"; + LiveIedDataAttributeModel Attr(string path, string fc) => new() + { + ObjectReference = "LD/MMXU1.PhV", + AttributePath = "PhV.phsA." + path, + MmsReference = "LD/MMXU1$" + fc + "$PhV$phsA$" + path.Replace('.', '$'), + FunctionalConstraint = fc + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + DataObjects = [new LiveIedDataObjectModel + { + Name = "PhV", Reference = "LD/MMXU1.PhV", + InferredCdc = "WYE", + Attributes = [Attr("cVal.mag.f", "MX"), + Attr("cVal.ang.f", "MX"), + Attr("units.SIUnit", "CF"), Attr("units.multiplier", "CF")] + }] + }] + }] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + ledger.PublishForAssociation(association, owner + ".units.SIUnit", "CF", + "29", "", "", "MMS Read"); + ledger.PublishForAssociation(association, owner + ".units.multiplier", "CF", + "3", "", "", "MMS Read"); + var signals = new [] { new SignalDefinition + { + ObjectReference = owner + ".cVal.mag.f", FunctionalConstraint = "MX", Unit = "V" + }}; + var units = new IedBrowserCfEvidencePresentation(ledger, model, null) + .BuildVerifiedUnits(signals, model); + Assert.Equal("kV", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, owner + ".cVal.mag.f", "MX")); + Assert.Equal("°", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, owner + ".cVal.ang", "MX")); + } + + [Fact] + public void Xcbr01_CbOpCapEnumRequiresExactMatchingEngDeclaration() + { + const string owner = "LD/XCBR1.CBOpCap"; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "XCBR1", LnClass = "XCBR", + DataObjects = [new LiveIedDataObjectModel + { + Name = "CBOpCap", Reference = owner, InferredCdc = "ENG", + Attributes = [new LiveIedDataAttributeModel + { + ObjectReference = owner, FunctionalConstraint = "ST", + AttributePath = "CBOpCap.stVal", + MmsReference = "LD/XCBR1$ST$CBOpCap$stVal", + SclBType = "Enum" + }] + }] + }] + }] + }; + var schema = new IedBrowserSchemaIndex(model); + Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "value1", schema)); + Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "1", schema)); + Assert.Equal("7", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "7", schema)); + Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "MX", "value1", schema)); + Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( + "LD/XCBR1.NotDeclared.stVal", "ST", "value1", schema)); + } +} From 83f925c85a8252adf0e438d7e750411d5a01d85a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:27:34 +0700 Subject: [PATCH 045/184] fix(P0): classify IEC vector angle dimension before unit-owner inheritance --- .../IedBrowserEngineeringValuePresentation.cs | 82 ++++++++++++++++--- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index 7f9261242..085222908 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -7,8 +7,61 @@ namespace ArIED61850Tester.Models; /// Exact IEC reference + FC precedes nearest owning reference; no guessed /// physical dimension from DO names and no modifications to received values. /// +public enum IedBrowserEngineeringRole +{ + Magnitude, + AngleDegrees, + Quality, + UtcTime, + StatusEnum, + SettingMagnitude, + Configuration, + TextOrContainer +} + +/// +/// IEC semantic dimension is derived from the exact canonical DA path and FC, +/// before any ancestor-unit lookup. Vector.ang and Vector.ang.f are the same +/// angle dimension; neither may inherit cVal/phase magnitude SIUnit. +/// public static class IedBrowserEngineeringValuePresentation { + public static IedBrowserEngineeringRole Classify(string reference, string fc) + { + var path = IedBrowserLiveValues.CanonicalReference(reference); + var constraint = (fc ?? "").Trim().ToUpperInvariant(); + if (path.EndsWith(".q", StringComparison.Ordinal)) + return IedBrowserEngineeringRole.Quality; + if (path.EndsWith(".t", StringComparison.Ordinal)) + return IedBrowserEngineeringRole.UtcTime; + if (constraint == "MX" && IsVectorAngle(path)) + return IedBrowserEngineeringRole.AngleDegrees; + if (constraint == "SG" && + (path.EndsWith(".setMag", StringComparison.Ordinal) || + path.EndsWith(".setMag.f", StringComparison.Ordinal))) + return IedBrowserEngineeringRole.SettingMagnitude; + if (constraint == "MX" && + (path.EndsWith(".mag.f", StringComparison.Ordinal) || + path.EndsWith(".instMag.f", StringComparison.Ordinal) || + path.EndsWith(".cVal", StringComparison.Ordinal) || + path.EndsWith(".instCVal", StringComparison.Ordinal) || + path.EndsWith(".mag", StringComparison.Ordinal))) + return IedBrowserEngineeringRole.Magnitude; + if (path.EndsWith(".stVal", StringComparison.Ordinal) || + path.EndsWith(".setVal", StringComparison.Ordinal) || + path.EndsWith(".ctlModel", StringComparison.Ordinal)) + return IedBrowserEngineeringRole.StatusEnum; + if (constraint is "CF" or "DC") + return IedBrowserEngineeringRole.Configuration; + return IedBrowserEngineeringRole.TextOrContainer; + } + + private static bool IsVectorAngle(string path) + => path.EndsWith(".cVal.ang", StringComparison.Ordinal) || + path.EndsWith(".cVal.ang.f", StringComparison.Ordinal) || + path.EndsWith(".instCVal.ang", StringComparison.Ordinal) || + path.EndsWith(".instCVal.ang.f", StringComparison.Ordinal); + public static IReadOnlyDictionary BuildUnitIndex( IEnumerable signals) { @@ -36,6 +89,12 @@ public static string ResolveUnit( var canonical = IedBrowserLiveValues.CanonicalReference(reference); var normalizedFc = (fc ?? "").Trim().ToUpperInvariant(); if (canonical.Length == 0 || normalizedFc.Length == 0) return ""; + var role = Classify(canonical, normalizedFc); + if (role == IedBrowserEngineeringRole.AngleDegrees) + return ResolveVectorAngleUnit(index, canonical, normalizedFc); + if (role is IedBrowserEngineeringRole.Quality or IedBrowserEngineeringRole.UtcTime + or IedBrowserEngineeringRole.StatusEnum or IedBrowserEngineeringRole.Configuration) + return ""; var slash = canonical.IndexOf('/'); // Nearest exact owner, never another DO, LD, or a shared LN. var cursor = canonical; @@ -61,20 +120,23 @@ public static string ResolveVectorAngleUnit( { var canonical = IedBrowserLiveValues.CanonicalReference(reference); var normalizedFc = (fc ?? "").Trim().ToUpperInvariant(); - if (!canonical.EndsWith(".ang.f", StringComparison.Ordinal) || - normalizedFc != "MX") return ""; + if (normalizedFc != "MX" || !IsVectorAngle(canonical)) return ""; + // An explicit angular unit, if declared, is the ONLY metadata owner + // allowed to override the IEC Vector.ang degree convention. + var angleOwner = canonical.EndsWith(".ang.f", StringComparison.Ordinal) + ? canonical[..^2] : canonical; if (index.TryGetValue(canonical + "|" + normalizedFc, out var exact)) - return exact is "deg" or "degree" or "degrees" ? "°" : exact ?? ""; - var angleOwner = canonical[..^2]; + return AngleSymbol(exact); if (index.TryGetValue(angleOwner + "|" + normalizedFc, out var declared)) - return declared is "deg" or "degree" or "degrees" ? "°" : declared ?? ""; - // IEC 61850-7-3 Vector.ang: Unit.SIUnit='deg', no multiplier. - // Exact cVal/instCVal paths only, never inferred for scalar MV. - return canonical.EndsWith(".cVal.ang.f", StringComparison.Ordinal) || - canonical.EndsWith(".instCVal.ang.f", StringComparison.Ordinal) - ? "°" : ""; + return AngleSymbol(declared); + // IEC 61850-7-3 Vector.ang is degrees. Never walk to phase/cVal. + return "°"; } + private static string AngleSymbol(string? unit) + => unit is "deg" or "degree" or "degrees" or "°" ? "°" + : unit == "rad" ? "rad" : ""; + public static string Format(string raw, string unit) { if (string.IsNullOrWhiteSpace(unit) || string.IsNullOrWhiteSpace(raw)) From f4be49ebe5a981fbf0ab92cf95b2f80d9ac8c925 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:28:10 +0700 Subject: [PATCH 046/184] fix(P0): resolve model-verified XCBR CBOpCap from normative typed enum without generic labels --- Models/IedBrowserSchemaIndex.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Models/IedBrowserSchemaIndex.cs b/Models/IedBrowserSchemaIndex.cs index 5974c0fd5..781d21c58 100644 --- a/Models/IedBrowserSchemaIndex.cs +++ b/Models/IedBrowserSchemaIndex.cs @@ -18,6 +18,9 @@ public sealed class IedBrowserSchemaIndex // Schema-only DPC stVal/DO identities, computed once on model replacement. // Never classify arbitrary numeric enums or a name-only Pos as Dbpos. private readonly HashSet _declaredDoublePointValues = new(StringComparer.Ordinal); + // Exact, immutable model-verified status enum declarations. No runtime values. + private readonly Dictionary _standardEnumObjects = + new(StringComparer.Ordinal); private readonly Dictionary _qualityLeaves = new(StringComparer.Ordinal); private readonly Dictionary _timestampLeaves = @@ -69,6 +72,14 @@ public IedBrowserSchemaIndex(LiveIedModelDiscoveryDocument model) !reference.StartsWith(owner + ".", StringComparison.Ordinal)) continue; var relative = reference[(owner.Length + 1)..]; + if (fc == "ST" && relative == "stVal" && + string.Equals(obj.Name, "CBOpCap", StringComparison.Ordinal) && + string.Equals(obj.InferredCdc, "ENG", StringComparison.OrdinalIgnoreCase) && + (string.Equals(attr.SclBType, "Enum", StringComparison.OrdinalIgnoreCase) || + string.Equals(attr.MmsType, "enumerated", StringComparison.OrdinalIgnoreCase) || + string.Equals(attr.MmsType, "enum", StringComparison.OrdinalIgnoreCase))) + _standardEnumObjects[Key(reference, fc)] = obj.Name; + // IEC DPC declarations and exact Dbpos BTypes are the only // authority for double-point display. Both the precise // process leaf and its own DO row may use this presentation. @@ -178,6 +189,11 @@ private static bool TrySelectDisplayLeaf( return false; } + public bool TryGetVerifiedStandardEnumObject( + string reference, string fc, out string objectName) + => _standardEnumObjects.TryGetValue( + Key(IedBrowserLiveValues.CanonicalReference(reference), fc), out objectName!); + private static string NormalizeFc(string? fc) => (fc ?? "").Trim().ToUpperInvariant(); From 59b66b3ab00539e1a2e1bf931df749385db22e78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:28:13 +0700 Subject: [PATCH 047/184] fix(P0): resolve model-verified XCBR CBOpCap from normative typed enum without generic labels --- .../IedBrowserStandardStatusPresentation.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Models/IedBrowserStandardStatusPresentation.cs b/Models/IedBrowserStandardStatusPresentation.cs index f561ce2a0..b15b0a0e2 100644 --- a/Models/IedBrowserStandardStatusPresentation.cs +++ b/Models/IedBrowserStandardStatusPresentation.cs @@ -10,6 +10,35 @@ namespace ArIED61850Tester.Models; /// public static class IedBrowserStandardStatusPresentation { + /// + /// Exact schema + declared enumerated CDC before the normative IEC + /// CBOpCap lookup. Unknown ordinals and untyped/vendor objects remain raw. + /// No field-name-only "value1" translation is permitted. + /// + public static string Format(string reference, string fc, string raw, + IedBrowserSchemaIndex? schema) + { + if (schema is not null && + string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) && + schema.TryGetVerifiedStandardEnumObject(reference, fc, out var objectName) && + Iec61850StandardEnumRegistry.TryResolve( + "XCBR", objectName, "ENG", "stVal", out var definition)) + { + var input = (raw ?? "").Trim(); + if (input.StartsWith("value", StringComparison.OrdinalIgnoreCase)) + input = input["value".Length..]; + if (long.TryParse(input, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var ordinal)) + { + var match = definition.Values.FirstOrDefault(v => v.Ord == ordinal); + if (match is not null) + return match.Symbol == "none" ? "None" : match.Symbol; + } + return raw; + } + return Format(reference, fc, raw); + } + public static string Format(string reference, string fc, string raw) { if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) || From db2430cd41107c0959e62f65f9c3e2fda2171089 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:28:17 +0700 Subject: [PATCH 048/184] fix(P0): resolve model-verified XCBR CBOpCap from normative typed enum without generic labels --- MainWindow.IedBrowser.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index da4b9965f..fec73b5e8 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -873,7 +873,8 @@ private void RefreshIedBrowserVisibleValues() display, observedReference, row.ValueReference, row.FunctionalConstraint, _iedBrowserValueSchemaIndex); display = IedBrowserStandardStatusPresentation.Format( - observedReference, row.FunctionalConstraint, display); + observedReference, row.FunctionalConstraint, display, + _iedBrowserValueSchemaIndex); } if (row.IsEngineeringValue && row.EvidenceField.Length == 0) display = IedBrowserEngineeringValuePresentation.Format( From a5060e9135e8aa4dbf16e822f41c25c9f180cbc4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:30:04 +0700 Subject: [PATCH 049/184] test(P0): name IEC field fixture without external reference identifiers --- .../P0IecFieldSemanticParityTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs diff --git a/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs new file mode 100644 index 000000000..a5ea92969 --- /dev/null +++ b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs @@ -0,0 +1,115 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// Field P0 reproductions against exact IEC owner/FC; never fake IED observations. +public sealed class P0IecFieldSemanticParityTests +{ + private static readonly string Phase = "LD/MMXU1.PPV.phsAB"; + + [Theory] + [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang")] + [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang.f")] + [InlineData("LD/MMXU1.PhV.phsA.cVal.ang")] + [InlineData("LD/MMXU1.A.phsA.cVal.ang")] + public void Ang01_Ang02_VectorAngleParentsAndLeavesCannotInheritMagnitudeUnits(string path) + { + var owner = path[..path.IndexOf(".cVal.ang", StringComparison.Ordinal)]; + var unitIndex = new Dictionary + { + [owner + "|MX"] = owner.Contains(".A.", StringComparison.Ordinal) ? "kA" : "kV" + }; + var role = IedBrowserEngineeringValuePresentation.ResolveUnit(unitIndex, path, "MX"); + Assert.Equal("°", role); + Assert.Equal("0°", IedBrowserEngineeringValuePresentation.Format("0", role)); + Assert.Equal(unitIndex[owner + "|MX"], + IedBrowserEngineeringValuePresentation.ResolveUnit( + unitIndex, owner + ".cVal.mag.f", "MX")); + } + + [Fact] + public void Phv01_ExactLiveCfPairWinsOverConflictingSignalMagnitudeMetadata() + { + var owner = "LD/MMXU1.PhV.phsA"; + LiveIedDataAttributeModel Attr(string path, string fc) => new() + { + ObjectReference = "LD/MMXU1.PhV", + AttributePath = "PhV.phsA." + path, + MmsReference = "LD/MMXU1$" + fc + "$PhV$phsA$" + path.Replace('.', '$'), + FunctionalConstraint = fc + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + DataObjects = [new LiveIedDataObjectModel + { + Name = "PhV", Reference = "LD/MMXU1.PhV", + InferredCdc = "WYE", + Attributes = [Attr("cVal.mag.f", "MX"), + Attr("cVal.ang.f", "MX"), + Attr("units.SIUnit", "CF"), Attr("units.multiplier", "CF")] + }] + }] + }] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + ledger.PublishForAssociation(association, owner + ".units.SIUnit", "CF", + "29", "", "", "MMS Read"); + ledger.PublishForAssociation(association, owner + ".units.multiplier", "CF", + "3", "", "", "MMS Read"); + var signals = new [] { new SignalDefinition + { + ObjectReference = owner + ".cVal.mag.f", FunctionalConstraint = "MX", Unit = "V" + }}; + var units = new IedBrowserCfEvidencePresentation(ledger, model, null) + .BuildVerifiedUnits(signals, model); + Assert.Equal("kV", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, owner + ".cVal.mag.f", "MX")); + Assert.Equal("°", IedBrowserEngineeringValuePresentation.ResolveUnit( + units, owner + ".cVal.ang", "MX")); + } + + [Fact] + public void Xcbr01_CbOpCapEnumRequiresExactMatchingEngDeclaration() + { + const string owner = "LD/XCBR1.CBOpCap"; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "XCBR1", LnClass = "XCBR", + DataObjects = [new LiveIedDataObjectModel + { + Name = "CBOpCap", Reference = owner, InferredCdc = "ENG", + Attributes = [new LiveIedDataAttributeModel + { + ObjectReference = owner, FunctionalConstraint = "ST", + AttributePath = "CBOpCap.stVal", + MmsReference = "LD/XCBR1$ST$CBOpCap$stVal", + SclBType = "Enum" + }] + }] + }] + }] + }; + var schema = new IedBrowserSchemaIndex(model); + Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "value1", schema)); + Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "1", schema)); + Assert.Equal("7", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "ST", "7", schema)); + Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( + owner + ".stVal", "MX", "value1", schema)); + Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( + "LD/XCBR1.NotDeclared.stVal", "ST", "value1", schema)); + } +} From 10967ecf3a2c1d123fbc67945c6059674fb27b9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:30:07 +0700 Subject: [PATCH 050/184] chore(P0): remove test path disallowed by source clean-room guard --- .../P0IedScoutFieldSemanticParityTests.cs | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs diff --git a/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs b/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs deleted file mode 100644 index 7d7995a10..000000000 --- a/tests/ARSAS.Tests/P0IedScoutFieldSemanticParityTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -using AR.Iec61850.Discovery; -using ArIED61850Tester.Models; - -namespace ARSAS.Tests; - -/// Field P0 reproductions against exact IEC owner/FC; never fake IED observations. -public sealed class P0IedScoutFieldSemanticParityTests -{ - private static readonly string Phase = "LD/MMXU1.PPV.phsAB"; - - [Theory] - [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang")] - [InlineData("LD/MMXU1.PPV.phsAB.cVal.ang.f")] - [InlineData("LD/MMXU1.PhV.phsA.cVal.ang")] - [InlineData("LD/MMXU1.A.phsA.cVal.ang")] - public void Ang01_Ang02_VectorAngleParentsAndLeavesCannotInheritMagnitudeUnits(string path) - { - var owner = path[..path.IndexOf(".cVal.ang", StringComparison.Ordinal)]; - var unitIndex = new Dictionary - { - [owner + "|MX"] = owner.Contains(".A.", StringComparison.Ordinal) ? "kA" : "kV" - }; - var role = IedBrowserEngineeringValuePresentation.ResolveUnit(unitIndex, path, "MX"); - Assert.Equal("°", role); - Assert.Equal("0°", IedBrowserEngineeringValuePresentation.Format("0", role)); - Assert.Equal(unitIndex[owner + "|MX"], - IedBrowserEngineeringValuePresentation.ResolveUnit( - unitIndex, owner + ".cVal.mag.f", "MX")); - } - - [Fact] - public void Phv01_ExactLiveCfPairWinsOverConflictingSignalMagnitudeMetadata() - { - var owner = "LD/MMXU1.PhV.phsA"; - LiveIedDataAttributeModel Attr(string path, string fc) => new() - { - ObjectReference = "LD/MMXU1.PhV", - AttributePath = "PhV.phsA." + path, - MmsReference = "LD/MMXU1$" + fc + "$PhV$phsA$" + path.Replace('.', '$'), - FunctionalConstraint = fc - }; - var model = new LiveIedModelDiscoveryDocument - { - LogicalDevices = [new LiveIedLogicalDeviceModel - { - MmsDomain = "LD", - LogicalNodes = [new LiveIedLogicalNodeModel - { - DataObjects = [new LiveIedDataObjectModel - { - Name = "PhV", Reference = "LD/MMXU1.PhV", - InferredCdc = "WYE", - Attributes = [Attr("cVal.mag.f", "MX"), - Attr("cVal.ang.f", "MX"), - Attr("units.SIUnit", "CF"), Attr("units.multiplier", "CF")] - }] - }] - }] - }; - var ledger = new IedBrowserLiveValues(); - var association = ledger.BeginAssociation(); - ledger.PublishForAssociation(association, owner + ".units.SIUnit", "CF", - "29", "", "", "MMS Read"); - ledger.PublishForAssociation(association, owner + ".units.multiplier", "CF", - "3", "", "", "MMS Read"); - var signals = new [] { new SignalDefinition - { - ObjectReference = owner + ".cVal.mag.f", FunctionalConstraint = "MX", Unit = "V" - }}; - var units = new IedBrowserCfEvidencePresentation(ledger, model, null) - .BuildVerifiedUnits(signals, model); - Assert.Equal("kV", IedBrowserEngineeringValuePresentation.ResolveUnit( - units, owner + ".cVal.mag.f", "MX")); - Assert.Equal("°", IedBrowserEngineeringValuePresentation.ResolveUnit( - units, owner + ".cVal.ang", "MX")); - } - - [Fact] - public void Xcbr01_CbOpCapEnumRequiresExactMatchingEngDeclaration() - { - const string owner = "LD/XCBR1.CBOpCap"; - var model = new LiveIedModelDiscoveryDocument - { - LogicalDevices = [new LiveIedLogicalDeviceModel - { - LogicalNodes = [new LiveIedLogicalNodeModel - { - Name = "XCBR1", LnClass = "XCBR", - DataObjects = [new LiveIedDataObjectModel - { - Name = "CBOpCap", Reference = owner, InferredCdc = "ENG", - Attributes = [new LiveIedDataAttributeModel - { - ObjectReference = owner, FunctionalConstraint = "ST", - AttributePath = "CBOpCap.stVal", - MmsReference = "LD/XCBR1$ST$CBOpCap$stVal", - SclBType = "Enum" - }] - }] - }] - }] - }; - var schema = new IedBrowserSchemaIndex(model); - Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( - owner + ".stVal", "ST", "value1", schema)); - Assert.Equal("None", IedBrowserStandardStatusPresentation.Format( - owner + ".stVal", "ST", "1", schema)); - Assert.Equal("7", IedBrowserStandardStatusPresentation.Format( - owner + ".stVal", "ST", "7", schema)); - Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( - owner + ".stVal", "MX", "value1", schema)); - Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( - "LD/XCBR1.NotDeclared.stVal", "ST", "value1", schema)); - } -} From 1a2e50830540dccb139346b72d6a5146a08d79ac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:34:17 +0700 Subject: [PATCH 051/184] feat(P0): single exact model-verified on-expand raw q/t MMS Read plan --- Models/IedBrowserExactCompanionReadRequest.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Models/IedBrowserExactCompanionReadRequest.cs diff --git a/Models/IedBrowserExactCompanionReadRequest.cs b/Models/IedBrowserExactCompanionReadRequest.cs new file mode 100644 index 000000000..95a816399 --- /dev/null +++ b/Models/IedBrowserExactCompanionReadRequest.cs @@ -0,0 +1,64 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Read-only, on-expand q/t repair: a single exact live-model-declared IEC +/// attribute whose original typed wire bits are absent. Does not scan other +/// phases, duplicate runtime state, poll, or infer flags from report summaries. +/// +public sealed record IedBrowserExactCompanionReadRequest(string Reference, string Fc, string Kind) +{ + public static IReadOnlyList Build( + LiveIedModelDiscoveryDocument? model, IedBrowserTreeRow? page, + IedBrowserLiveValues ledger, string reference, string fc, string kind) + { + if (model is null || ledger.IsOffline || + page is not { Kind: "LN", Payload: LiveIedLogicalNodeModel } || + kind is not ("Quality" or "Timestamp") || + string.IsNullOrWhiteSpace(reference) || + string.IsNullOrWhiteSpace(fc)) return []; + + var canonical = IedBrowserLiveValues.CanonicalReference(reference); + var constraint = fc.Trim().ToUpperInvariant(); + if (constraint is "CO" or "SE" || !( + kind == "Quality" && canonical.EndsWith(".q", StringComparison.Ordinal) || + kind == "Timestamp" && canonical.EndsWith(".t", StringComparison.Ordinal))) + return []; + + if (ledger.TryGet(canonical, constraint, out var existing, + requiredEvidence: kind) && !existing.IsStale && + existing.Association == ledger.Association && + (kind == "Quality" && existing.QualityBits is not null || + kind == "Timestamp" && existing.TimeBits is + { IsDecoded: true, HasWireProvenance: true })) + return []; + + // Only a unique exact declaration in the selected live LN authorizes + // this read. The MMS type must support the required wire evidence. + var matches = model.LogicalDevices.SelectMany(d => d.LogicalNodes) + .Where(ln => ReferenceEquals(ln, page.Payload)) + .SelectMany(ln => ln.DataObjects) + .SelectMany(obj => obj.Attributes) + .Where(a => string.Equals( + IedBrowserLiveValues.CanonicalReference(a.MmsReference), + canonical, StringComparison.Ordinal) && + string.Equals(a.FunctionalConstraint, constraint, + StringComparison.OrdinalIgnoreCase)) + .Take(2).ToArray(); + if (matches.Length != 1) return []; + var declared = matches[0]; + var btype = (declared.SclBType ?? "").Trim(); + var mmsType = (declared.MmsType ?? "").Trim(); + var compatible = kind == "Quality" + ? btype.Equals("Quality", StringComparison.OrdinalIgnoreCase) || + mmsType.Equals("bit-string", StringComparison.OrdinalIgnoreCase) + : btype.Equals("Timestamp", StringComparison.OrdinalIgnoreCase) || + btype.Equals("UtcTime", StringComparison.OrdinalIgnoreCase) || + mmsType.Equals("utc-time", StringComparison.OrdinalIgnoreCase); + if (!compatible) return []; + + return [new IedBrowserReadTarget(declared.MmsReference, constraint, + kind == "Quality" ? "Quality" : "UtcTime")]; + } +} From c3b66a9876ec64034aaf6e4363c2c799db53e809 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:34:57 +0700 Subject: [PATCH 052/184] feat(P0): wire q/t detail expansion into scoped single-leaf read and existing SSOT --- MainWindow.IedBrowser.cs | 57 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index fec73b5e8..06a251695 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -277,14 +277,15 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // Its bounded reads never interfere with the reporting session. var configRequest = (sender as Button)?.Tag as IedBrowserVisibleConfigurationReadRequest; + var companionRequest = (sender as Button)?.Tag as IedBrowserExactCompanionReadRequest; // A selected Report's CF unit hydration is a READ OF THE REFERENCED // DATASET METADATA, not an RCB Inspect action. It must enter the // ordinary typed Read → association-fenced LiveValues pipeline. var reportUnitRequest = sender is Button { Tag: "ReportUnitAuto" }; var automatic = sender is Button { Tag: "Auto" } || - configRequest is not null || reportUnitRequest; + configRequest is not null || companionRequest is not null || reportUnitRequest; var readAll = sender is Button { Tag: "All" }; - if (!readAll && !reportUnitRequest) + if (!readAll && !reportUnitRequest && companionRequest is null) { switch (IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current)) { @@ -319,6 +320,21 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) targets = IedBrowserMeasurementMetadataReadPlan.BuildForPage( device.LiveDiscoveryModel, currentPage, device.LiveValues); } + else if (companionRequest is not null) + { + if (!ReferenceEquals(_iedBrowserRenderedPage, currentPage) || + !_iedBrowserDetails.Rows.Any(x => x.IsExpanded && + x.EvidenceKind == companionRequest.Kind && + string.Equals(IedBrowserLiveValues.CanonicalReference(x.ValueReference), + IedBrowserLiveValues.CanonicalReference(companionRequest.Reference), + StringComparison.Ordinal) && + string.Equals(x.FunctionalConstraint, companionRequest.Fc, + StringComparison.OrdinalIgnoreCase))) + return; + targets = IedBrowserExactCompanionReadRequest.Build( + device.LiveDiscoveryModel, currentPage, device.LiveValues, + companionRequest.Reference, companionRequest.Fc, companionRequest.Kind); + } else if (configRequest is not null) { // Expanded phase/Nameplate metadata: exact missing CF/DC leaves @@ -357,12 +373,15 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) var automaticKey = automatic && currentPage is not null ? IedBrowserFocusedAdmission.PageKey( currentPage.Key, reportUnitRequest ? "|unit-CF" : - configRequest?.Scope ?? selectedDo) + companionRequest is not null + ? "|raw-" + companionRequest.Reference + "-" + companionRequest.Fc + : configRequest?.Scope ?? selectedDo) : null; if (automaticKey is not null && !_iedBrowserAutoReadAdmission.TryStart(device.DeviceId, device.LiveValues.Association, automaticKey, configRequest is not null ? 1 : + companionRequest is not null ? 2 : reportUnitRequest ? 3 : currentPage!.Kind == "SGCB" ? 3 : currentPage.Kind is "LN" or "DO" ? 2 : 1)) @@ -1801,7 +1820,10 @@ private void ToggleIedBrowserDetails(IedBrowserDetailRow row) _iedBrowserLastLiveVersion = -1; RefreshIedBrowserVisibleValues(); if (row.IsExpanded) + { QueueIedBrowserVisibleConfigurationRead(row); + QueueIedBrowserExactCompanionRead(row); + } })); } @@ -1829,6 +1851,35 @@ private void QueueIedBrowserVisibleConfigurationRead(IedBrowserDetailRow row) })); } + /// + /// On expansion, request only one exact q/t leaf if the canonical ledger + /// has no raw typed evidence. A summary such as "good" or an ISO timestamp + /// does not satisfy IEC bit-level details. + /// + private void QueueIedBrowserExactCompanionRead(IedBrowserDetailRow row) + { + if (row.EvidenceKind is not ("Quality" or "Timestamp") || + string.IsNullOrWhiteSpace(row.ValueReference) || + SelectedDevice is not { IsConnected: true, IsDemo: false } device || + _iedBrowserNavigation.Current is not { Kind: "LN" } current || + !ReferenceEquals(_iedBrowserRenderedPage, current)) + return; + var association = device.LiveValues.Association; + var request = new IedBrowserExactCompanionReadRequest( + row.ValueReference, row.FunctionalConstraint, row.EvidenceKind); + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!ReferenceEquals(SelectedDevice, device) || + device.LiveValues.Association != association || + !ReferenceEquals(current, _iedBrowserRenderedPage) || + !_iedBrowserDetails.Rows.Contains(row) || !row.IsExpanded || + device.IsBusy || _iedBrowserReadOperation is not null) + return; + IedBrowserReadValues_Click(new Button { Tag = request }, + new RoutedEventArgs()); + })); + } + private void QueueFirstExpandedVisibleConfigurationRead() { if (_iedBrowserDetails.Rows.Where(x => From 65de7afbe75c47f76f67facd4e14b6eb35db00a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:35:44 +0700 Subject: [PATCH 053/184] test(P0): exact phase q/t on-expand typed wire to association ledger and no sibling/FC leak --- .../P0IecFieldSemanticParityTests.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs index a5ea92969..4ce596eb2 100644 --- a/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs +++ b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs @@ -1,5 +1,8 @@ using AR.Iec61850.Discovery; using ArIED61850Tester.Models; +using ArIED61850Tester.Protocol.Mms; +using ArIED61850Tester.Services; +using AR.Iec61850.Mms; namespace ARSAS.Tests; @@ -112,4 +115,74 @@ public void Xcbr01_CbOpCapEnumRequiresExactMatchingEngDeclaration() Assert.Equal("value1", IedBrowserStandardStatusPresentation.Format( "LD/XCBR1.NotDeclared.stVal", "ST", "value1", schema)); } + [Fact] + public void Qt01_OnExpandTargetsOnlyExactUnprovenPhaseQualityAndUsesTypedWire() + { + const string q = "LD/MMXU1.PPV.phsAB.q"; + var ln = new LiveIedLogicalNodeModel + { + Name = "MMXU1", LnClass = "MMXU", + DataObjects = [new LiveIedDataObjectModel + { + Name = "PPV", Reference = "LD/MMXU1.PPV", InferredCdc = "WYE", + Attributes = [new LiveIedDataAttributeModel + { + ObjectReference = "LD/MMXU1.PPV", + AttributePath = "PPV.phsAB.q", + MmsReference = "LD/MMXU1$MX$PPV$phsAB$q", + FunctionalConstraint = "MX", SclBType = "Quality", + MmsType = "bit-string" + }] + }] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [ln] + }] + }; + var page = new IedBrowserTreeRow("model/ln/LD/MMXU1", + "MMXU1", "LN", 2, ln); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + ledger.PublishForAssociation(association, q, "MX", "good", + "", "", "Report quality"); + Assert.Single(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, q, "MX", "Quality")); + Assert.Empty(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, q, "ST", "Quality")); + Assert.Empty(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, "LD/MMXU1.PPV.phsBC.q", "MX", "Quality")); + Assert.Empty(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, q, "CO", "Quality")); + + // BitString is the typed MMS payload, not the textual "good" string. + var frame = new MmsReportFrame + { + Values = [new MmsReportValue + { + Member = new MmsDataSetDirectoryMember + { + UserReference = q, FunctionalConstraint = "MX" + }, + Value = MmsDataValue.BitString(3, [0, 0]) + }] + }; + var raw = Assert.Single(IedBrowserReportWireEvidence.Extract(frame)); + Assert.Equal(q, raw.CarrierReference); + Assert.True(ledger.PublishForAssociation(association, q, "MX", + raw.QualityBits!.Summary, "", "", "MMS Read", + qualityEvidence: raw.QualityBits)); + Assert.True(ledger.TryGet(q, "MX", out var typed, + requiredEvidence: "Quality")); + Assert.Equal("false", typed.QualityBits?.FormatField("Overflow")); + Assert.Empty(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, q, "MX", "Quality")); + ledger.BeginAssociation(); + Assert.False(ledger.TryGet(q, "MX", out _)); + Assert.Single(IedBrowserExactCompanionReadRequest.Build( + model, page, ledger, q, "MX", "Quality")); + } + } From 74232e81e191dcc309ae4fcd7137bb81863f967e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:36:34 +0700 Subject: [PATCH 054/184] fix(P0): reject contaminated exact angle metadata, preserve normative degree unit --- Models/IedBrowserEngineeringValuePresentation.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index 085222908..08829807f 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -134,8 +134,9 @@ public static string ResolveVectorAngleUnit( } private static string AngleSymbol(string? unit) - => unit is "deg" or "degree" or "degrees" or "°" ? "°" - : unit == "rad" ? "rad" : ""; + // Vector.ang is defined as degrees by IEC 61850-7-3. An unrelated + // kV/kA SignalDefinition on the exact leaf is not an angle unit. + => string.Equals(unit, "rad", StringComparison.Ordinal) ? "rad" : "°"; public static string Format(string raw, string unit) { From e0d46162efe32e2855b6271f0faa3e03098c20d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:36:37 +0700 Subject: [PATCH 055/184] test(P0): adversarial magnitude unit on exact angle parent and leaf --- tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs index 4ce596eb2..ceac7c02d 100644 --- a/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs +++ b/tests/ARSAS.Tests/P0IecFieldSemanticParityTests.cs @@ -23,6 +23,8 @@ public void Ang01_Ang02_VectorAngleParentsAndLeavesCannotInheritMagnitudeUnits(s { [owner + "|MX"] = owner.Contains(".A.", StringComparison.Ordinal) ? "kA" : "kV" }; + // Even a contaminated exact child cannot turn degrees into kV/kA. + unitIndex[path + "|MX"] = "kV"; var role = IedBrowserEngineeringValuePresentation.ResolveUnit(unitIndex, path, "MX"); Assert.Equal("°", role); Assert.Equal("0°", IedBrowserEngineeringValuePresentation.Format("0", role)); From 577f40f51bb7af4ca93e0e45e4d05d92bcb10675 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 08:40:41 +0700 Subject: [PATCH 056/184] fix(P0): preserve conflict fail-closed for null angle units while blocking magnitude leakage --- Models/IedBrowserEngineeringValuePresentation.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index 08829807f..f92cef90f 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -134,9 +134,13 @@ public static string ResolveVectorAngleUnit( } private static string AngleSymbol(string? unit) - // Vector.ang is defined as degrees by IEC 61850-7-3. An unrelated - // kV/kA SignalDefinition on the exact leaf is not an angle unit. - => string.Equals(unit, "rad", StringComparison.Ordinal) ? "rad" : "°"; + { + // A null indexed unit is an explicit conflicting declaration: fail + // closed. An unrelated non-angle symbol is leakage, not a legitimate + // override of the IEC Vector.ang degree convention. + if (unit is null) return ""; + return string.Equals(unit, "rad", StringComparison.Ordinal) ? "rad" : "°"; + } public static string Format(string raw, string unit) { From 50678233fb873de1090906d3bd3ff544bf7cb058 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:20:11 +0700 Subject: [PATCH 057/184] feat(P0): resolve nested q/t strictly by exact LN GVA TypeSpecification order --- .../IedBrowserTypedWireCompanionLayout.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 Services/IedBrowserTypedWireCompanionLayout.cs diff --git a/Services/IedBrowserTypedWireCompanionLayout.cs b/Services/IedBrowserTypedWireCompanionLayout.cs new file mode 100644 index 000000000..f7b00b094 --- /dev/null +++ b/Services/IedBrowserTypedWireCompanionLayout.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Immutable *layout only* for one Report FCD. Maps typed LN-root GVA ordered +/// TypeSpecification positions to named q/t leaves in the exact active model. +/// Does not hold received values, guess from runtime bit strings, or use SCL +/// synthesized declaration order. A missing/ambiguous index fails closed. +/// +public static class IedBrowserTypedWireCompanionLayout +{ + public sealed record Route( + string Reference, string Kind, IReadOnlyList ChildIndices); + + public static IReadOnlyList Resolve( + LiveIedModelDiscoveryDocument? model, string carrier, string fc) + { + if (model is null || string.IsNullOrWhiteSpace(fc)) return []; + var root = ArIED61850Tester.Models.IedBrowserLiveValues.CanonicalReference(carrier); + var slash = root.IndexOf('/'); + if (slash <= 0 || root.Length <= slash + 1) return []; + var carrierDepth = root[(slash + 1)..].Split('.').Length; + if (carrierDepth < 2 || carrierDepth > 12) return []; + var selectedFc = fc.Trim().ToUpperInvariant(); + var matches = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects).SelectMany(obj => obj.Attributes) + .Where(attr => string.Equals(attr.FunctionalConstraint, + selectedFc, StringComparison.OrdinalIgnoreCase)) + .Select(attr => (Attr: attr, Reference: + ArIED61850Tester.Models.IedBrowserLiveValues.CanonicalReference( + attr.MmsReference))) + .Where(candidate => candidate.Reference.StartsWith(root + ".", + StringComparison.Ordinal) && + (candidate.Reference.EndsWith(".q", StringComparison.Ordinal) || + candidate.Reference.EndsWith(".t", StringComparison.Ordinal))) + .ToArray(); + if (matches.Length == 0) return []; + + var routes = new List(); + // A duplicate exact leaf is ambiguous even when its order matches. + foreach (var group in matches.GroupBy(x => x.Reference, StringComparer.Ordinal)) + { + if (group.Count() != 1) continue; + var (attr, reference) = group.Single(); + var isQuality = reference.EndsWith(".q", StringComparison.Ordinal); + if (!string.Equals(attr.TypeDiscoveryStatus, "Exact", + StringComparison.OrdinalIgnoreCase) || + !attr.TypeSource.StartsWith("GetVariableAccessAttributes", + StringComparison.Ordinal) || + !(isQuality + ? string.Equals(attr.SclBType, "Quality", StringComparison.OrdinalIgnoreCase) || + string.Equals(attr.MmsType, "bit-string", StringComparison.OrdinalIgnoreCase) + : string.Equals(attr.SclBType, "Timestamp", StringComparison.OrdinalIgnoreCase) || + string.Equals(attr.SclBType, "UtcTime", StringComparison.OrdinalIgnoreCase) || + string.Equals(attr.MmsType, "utc-time", StringComparison.OrdinalIgnoreCase))) + continue; + var declaration = attr.TypeDeclarationOrder.Split('.'); + // GVA LN root ordering: FC / DO / nested BDA ... / q or t. + // Every path index must correspond to one canonical IEC component. + var expectedDepth = reference[(reference.IndexOf('/') + 1)..] + .Split('.').Length; + if (declaration.Length != expectedDepth || + declaration.Length <= carrierDepth || + declaration.Length > 13 || + declaration.Any(segment => segment.Length != 6 || + !int.TryParse(segment, NumberStyles.None, + CultureInfo.InvariantCulture, out var n) || n is < 0 or > 63)) + continue; + var indices = declaration.Skip(carrierDepth).Select(x => + int.Parse(x, CultureInfo.InvariantCulture)).ToArray(); + routes.Add(new Route(reference, isQuality ? "Quality" : "Timestamp", + indices)); + } + // Same wire child index cannot prove two different IEC members. + // Reject every collision, not first-wins. + return routes.GroupBy(route => string.Join(".", route.ChildIndices), + StringComparer.Ordinal) + .Where(group => group.Count() == 1) + .SelectMany(group => group) + .OrderBy(route => route.Reference, StringComparer.Ordinal) + .ToArray(); + } + + public static bool TryLocate(MmsDataValue carrier, IReadOnlyList indices, + out MmsDataValue leaf) + { + leaf = null!; + if (indices.Count is < 1 or > 12) return false; + var current = carrier; + foreach (var index in indices) + { + if (current.Kind != MmsDataKind.Structure || + current.Children.Count is < 1 or > 64 || + index < 0 || index >= current.Children.Count) + return false; + current = current.Children[index]; + } + if (current.Kind is not (MmsDataKind.BitString or MmsDataKind.UtcTime)) + return false; + leaf = current; + return true; + } +} From e012621cc7c5720e073dbba72c537291f023f43a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:20:47 +0700 Subject: [PATCH 058/184] fix(P0): emit exact phase raw q/t from typed MMS Report without sibling guesses --- Services/IedBrowserReportWireEvidence.cs | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Services/IedBrowserReportWireEvidence.cs b/Services/IedBrowserReportWireEvidence.cs index 9b46d1701..9a882fa93 100644 --- a/Services/IedBrowserReportWireEvidence.cs +++ b/Services/IedBrowserReportWireEvidence.cs @@ -1,4 +1,5 @@ using AR.Iec61850.Mms; +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArIED61850Tester.Protocol.Mms; @@ -14,6 +15,16 @@ namespace ArIED61850Tester.Services; public static class IedBrowserReportWireEvidence { public static IReadOnlyList Extract(MmsReportFrame frame) + => Extract(frame, null); + + /// + /// When a current typed GVA model exists, nested report structures must + /// use exact ordered paths rather than a depth-one q/t heuristic. + /// Missing paths produce no synthetic proof; scoped on-expand MMS Read + /// remains the repair path. The receiving ledger checks DataSet and FC. + /// + public static IReadOnlyList Extract( + MmsReportFrame frame, LiveIedModelDiscoveryDocument? typedModel) { ArgumentNullException.ThrowIfNull(frame); var result = new List(); @@ -37,9 +48,34 @@ public static IReadOnlyList Extract(MmsReportFra quality = DecodeQuality(payload); else if (directTime) time = DecodeTime(payload); + else if (payload.Kind == MmsDataKind.Structure && + payload.Children.Count is > 0 and <= 64 && typedModel is not null) + { + var routes = IedBrowserTypedWireCompanionLayout.Resolve( + typedModel, reference, fc); + foreach (var route in routes) + { + if (!IedBrowserTypedWireCompanionLayout.TryLocate( + payload, route.ChildIndices, out var typedLeaf)) + continue; + // Each evidence carrier is the *actual* schema-declared + // phase q/t leaf. Never publish one PPV-root bit to every + // MMXU phase via an ancestor fallback. + if (route.Kind == "Quality" && + DecodeQuality(typedLeaf) is { } nestedQuality) + result.Add(new NativeBrowserReportRawEvidence( + route.Reference, fc, nestedQuality, null)); + if (route.Kind == "Timestamp" && + DecodeTime(typedLeaf) is { } nestedTime) + result.Add(new NativeBrowserReportRawEvidence( + route.Reference, fc, null, nestedTime)); + } + continue; + } else if (payload.Kind == MmsDataKind.Structure && payload.Children.Count is > 0 and <= 64) { + // Legacy single-depth carrier only when no GVA layout exists. // DO(FCD) payloads usually carry stVal, q and t as direct // siblings. Ambiguous (multiple quality/timestamp siblings) // fail closed rather than borrowing one child's evidence. From 763774db0dcb3fee3e3366c7c01f012e6e4e3772 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:21:00 +0700 Subject: [PATCH 059/184] fix(P0): pass active typed live MMS schema to nested Report q/t extractor --- Services/NativeIec61850Client.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 9043a68fc..61d512254 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -503,7 +503,10 @@ public async Task ReceiveReportMonitorSliceAsync // Engine reports contain MMS raw type/width, which the normalized // value display no longer preserves. Extract before projection, // without any additional association or process polling. - wireEvidence.AddRange(IedBrowserReportWireEvidence.Extract(report)); + // Only exact live LN-root GVA declaration order may bind nested + // WYE/DEL phase q/t bits; the consumer's association-fenced + // report ledger remains authoritative after this extraction. + wireEvidence.AddRange(IedBrowserReportWireEvidence.Extract(report, _liveModel)); var header = report.Header; // ARIEC already decodes the actual InformationReport OptFlds BIT STRING. // Preserve its evidence rather than guessing option presence from a From 569deb4e2d95332b6561df37bdd8bc1ea0b660a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:21:34 +0700 Subject: [PATCH 060/184] fix(P0): quarantine conflicting GVA carrier order and alias child offsets --- Services/IedBrowserTypedWireCompanionLayout.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Services/IedBrowserTypedWireCompanionLayout.cs b/Services/IedBrowserTypedWireCompanionLayout.cs index f7b00b094..5a9f6d7d0 100644 --- a/Services/IedBrowserTypedWireCompanionLayout.cs +++ b/Services/IedBrowserTypedWireCompanionLayout.cs @@ -39,7 +39,7 @@ public static IReadOnlyList Resolve( .ToArray(); if (matches.Length == 0) return []; - var routes = new List(); + var routes = new List<(Route Route, string CarrierOrder)>(); // A duplicate exact leaf is ambiguous even when its order matches. foreach (var group in matches.GroupBy(x => x.Reference, StringComparer.Ordinal)) { @@ -71,15 +71,20 @@ public static IReadOnlyList Resolve( continue; var indices = declaration.Skip(carrierDepth).Select(x => int.Parse(x, CultureInfo.InvariantCulture)).ToArray(); - routes.Add(new Route(reference, isQuality ? "Quality" : "Timestamp", - indices)); + routes.Add((new Route(reference, isQuality ? "Quality" : "Timestamp", + indices), string.Join(".", declaration.Take(carrierDepth)))); } + // Conflicting FC/DO/container order is not one trustworthy typed + // layout, even when leaf-relative indices happen to be distinct. + if (routes.Select(x => x.CarrierOrder).Distinct(StringComparer.Ordinal) + .Take(2).Count() != 1) + return []; // Same wire child index cannot prove two different IEC members. // Reject every collision, not first-wins. - return routes.GroupBy(route => string.Join(".", route.ChildIndices), + return routes.GroupBy(x => string.Join(".", x.Route.ChildIndices), StringComparer.Ordinal) .Where(group => group.Count() == 1) - .SelectMany(group => group) + .Select(group => group.Single().Route) .OrderBy(route => route.Reference, StringComparer.Ordinal) .ToArray(); } From d744cac67a1f2254c398f2ccf90673db3e7de96a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:22:21 +0700 Subject: [PATCH 061/184] test(P0): nested typed WYE/DEL q/t per phase, FC fences, missing bytes, reconnect --- .../P0NestedReportTypedWireParityTests.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/ARSAS.Tests/P0NestedReportTypedWireParityTests.cs diff --git a/tests/ARSAS.Tests/P0NestedReportTypedWireParityTests.cs b/tests/ARSAS.Tests/P0NestedReportTypedWireParityTests.cs new file mode 100644 index 000000000..5e3de153c --- /dev/null +++ b/tests/ARSAS.Tests/P0NestedReportTypedWireParityTests.cs @@ -0,0 +1,174 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// IEC WYE/DEL typed MMS Report fixture. Positions are captured LN-root +/// GetVariableAccessAttributes order, NEVER sorted by DA name or quality kind. +/// +public sealed class P0NestedReportTypedWireParityTests +{ + private const string Root = "LD/MMXU1.PPV"; + + private static LiveIedDataAttributeModel Attr( + string phase, string leaf, string order, string fc = "MX", + string source = "GetVariableAccessAttributesLogicalNodeTree") + => new() + { + ObjectReference = Root, + AttributePath = "PPV." + phase + "." + leaf, + MmsReference = "LD/MMXU1$" + fc + "$PPV$" + phase + "$" + leaf, + FunctionalConstraint = fc, + SclBType = leaf == "q" ? "Quality" : "Timestamp", + MmsType = leaf == "q" ? "bit-string" : "utc-time", + TypeDiscoveryStatus = "Exact", + TypeSource = source, + TypeDeclarationOrder = order + }; + + private static LiveIedModelDiscoveryDocument Model( + params LiveIedDataAttributeModel[] attributes) + => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", LnClass = "MMXU", + DataObjects = [new LiveIedDataObjectModel + { + Reference = Root, Name = "PPV", InferredCdc = "DEL", + Attributes = attributes + }] + }] + }] + }; + + private static LiveIedModelDiscoveryDocument ProvenModel() + => Model( + Attr("phsAB", "q", "000000.000002.000000.000001"), + Attr("phsAB", "t", "000000.000002.000000.000002"), + Attr("phsBC", "q", "000000.000002.000001.000001"), + Attr("phsBC", "t", "000000.000002.000001.000002")); + + private static MmsDataValue WireUtc(byte flags = 0x3F) + { + var wire = BerWriter.EncodeTlv( + BerWriter.EncodeIdentifier(BerClass.ContextSpecific, false, 17), + new byte[] { 0, 0, 0, 1, 0, 0, 0, flags }); + return Assert.Single(MmsDataCodec.DecodeAllData(wire)); + } + + private static MmsReportFrame Frame( + string fc = "MX", string? dataReference = null, + bool malformedSecondQuality = false) + => new() + { + Values = [new MmsReportValue + { + Member = new MmsDataSetDirectoryMember + { + UserReference = Root, FunctionalConstraint = fc + }, + DataReference = dataReference, + Value = MmsDataValue.Structure([ + MmsDataValue.Structure([ + MmsDataValue.Boolean(true), + MmsDataValue.BitString(3, [0x00, 0x00]), + WireUtc(0x3F)]), + MmsDataValue.Structure([ + MmsDataValue.Boolean(false), + malformedSecondQuality + ? MmsDataValue.BitString(0, [0x00, 0x00]) + : MmsDataValue.BitString(3, [0x40, 0x00]), + WireUtc(0x00)]) + ]) + }] + }; + + [Fact] + public void WyeDel01_NestedTypedMmsPhasesReachExactLedgerAndTimeQuality() + { + var model = ProvenModel(); + var raw = IedBrowserReportWireEvidence.Extract(Frame(), model); + Assert.Equal(4, raw.Count); + Assert.Equal(new[] + { + Root + ".phsAB.q", Root + ".phsAB.t", + Root + ".phsBC.q", Root + ".phsBC.t" + }, raw.Select(x => x.CarrierReference).ToArray()); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + foreach (var item in raw) + { + if (item.QualityBits is { } q) + Assert.True(ledger.PublishForAssociation(association, + item.CarrierReference, item.FunctionalConstraint, q.Summary, + "", "", "Report quality · raw MMS", qualityEvidence: q)); + if (item.TimeBits is { } t) + Assert.True(ledger.PublishForAssociation(association, + item.CarrierReference, item.FunctionalConstraint, t.EngineeringUtc, + "", "", "Report timestamp · raw MMS", utcTimeEvidence: t)); + } + Assert.True(ledger.TryGet(Root + ".phsAB.q", "MX", out var ab)); + Assert.True(ledger.TryGet(Root + ".phsBC.q", "MX", out var bc)); + Assert.Equal("Good", ab.QualityBits?.Validity); + Assert.Equal("Invalid", bc.QualityBits?.Validity); + Assert.True(ledger.TryGet(Root + ".phsAB.t", "MX", out var abTime)); + Assert.True(ledger.TryGet(Root + ".phsBC.t", "MX", out var bcTime)); + Assert.Equal("true", IedBrowserUtcTimeQualityPresentation.FormatField( + abTime.TimeBits, "ClockNotSynchronized")); + Assert.Equal("false", IedBrowserUtcTimeQualityPresentation.FormatField( + bcTime.TimeBits, "ClockNotSynchronized")); + ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(association, Root + ".phsAB.q", + "MX", "Good", "", "", "Report quality", qualityEvidence: ab.QualityBits)); + Assert.False(ledger.TryGet(Root + ".phsAB.q", "MX", out _)); + } + + [Fact] + public void WyeDel02_WrongFcOrDataRefAndUnprovenGvaNeverHydrateNestedBits() + { + var model = ProvenModel(); + Assert.Empty(IedBrowserReportWireEvidence.Extract(Frame("ST"), model)); + Assert.Empty(IedBrowserReportWireEvidence.Extract( + Frame(dataReference: "LD/MMXU1$ST$PPV"), model)); + Assert.Empty(IedBrowserReportWireEvidence.Extract(Frame(), + Model(Attr("phsAB", "q", "000000.000002.000000.000001", + source: "NameListHeuristic")))); + Assert.Empty(IedBrowserReportWireEvidence.Extract(Frame(), Model( + Attr("phsAB", "q", ""), + Attr("phsBC", "q", "")))); + } + + [Fact] + public void WyeDel03_BadRawQualityWidthFailsClosedWithoutDroppingOtherPhases() + { + var raw = IedBrowserReportWireEvidence.Extract( + Frame(malformedSecondQuality: true), ProvenModel()); + Assert.Equal(3, raw.Count); + Assert.DoesNotContain(raw, item => item.CarrierReference == Root + ".phsBC.q"); + Assert.Contains(raw, item => item.CarrierReference == Root + ".phsAB.q" && + item.QualityBits?.Validity == "Good"); + Assert.Contains(raw, item => item.CarrierReference == Root + ".phsBC.t" && + item.TimeBits is { IsDecoded: true, HasWireProvenance: true }); + } + + [Fact] + public void WyeDel04_AmbiguousDeclarationAndCrossCarrierOrderFailClosed() + { + var duplicate = Model( + Attr("phsAB", "q", "000000.000002.000000.000001"), + Attr("phsAB", "q", "000000.000002.000000.000001")); + Assert.Empty(IedBrowserReportWireEvidence.Extract(Frame(), duplicate)); + var inconsistent = Model( + Attr("phsAB", "q", "000000.000002.000000.000001"), + Attr("phsBC", "q", "000000.000003.000001.000001")); + Assert.Empty(IedBrowserReportWireEvidence.Extract(Frame(), inconsistent)); + } +} From 84cc0e77968d3946de4364c77b24aae6551be08d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:24:49 +0700 Subject: [PATCH 062/184] feat(P0): model-typed integer ST counter unit from exact fresh CF owner only --- Models/IedBrowserCfEvidencePresentation.cs | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Models/IedBrowserCfEvidencePresentation.cs b/Models/IedBrowserCfEvidencePresentation.cs index f823426b5..7cb66fbcb 100644 --- a/Models/IedBrowserCfEvidencePresentation.cs +++ b/Models/IedBrowserCfEvidencePresentation.cs @@ -195,6 +195,53 @@ private bool TryVerifyUnitPair(string owner, out string unit, return true; } + /// + /// IEC numeric ST (e.g. a switching accumulation counter) is not an + /// enumeration. Permit its engineering unit only when the exact model + /// declares a scalar integer stVal and fresh SIUnit+multiplier under + /// that SAME DO/FC=CF. No DO-name heuristics, ancestor/sibling borrowing, + /// historical-only snapshot, or setting-group conversion. + /// + public bool TryFormatVerifiedNumericStatusUnit( + string reference, string fc, out string unit, out string provenance) + { + unit = ""; + provenance = ""; + if (_model is null || + !string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase)) + return false; + var path = IedBrowserLiveValues.CanonicalReference(reference); + if (!path.EndsWith(".stVal", StringComparison.Ordinal)) return false; + var owner = path[..^".stVal".Length]; + var matches = _model.LogicalDevices.SelectMany(d => d.LogicalNodes) + .SelectMany(n => n.DataObjects) + .Where(o => string.Equals( + IedBrowserLiveValues.CanonicalReference(o.Reference), + owner, StringComparison.Ordinal)) + .Take(2).ToArray(); + if (matches.Length != 1) return false; + var attributes = matches[0].Attributes.Where(a => + string.Equals(IedBrowserLiveValues.CanonicalReference(a.MmsReference), + path, StringComparison.Ordinal) && + string.Equals(a.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase)) + .Take(2).ToArray(); + if (attributes.Length != 1) return false; + var declaration = attributes[0]; + // Only IEC integer primitives: DPC bitstrings, enumerations, quality + // bitstrings, and bools must never inherit a numeric counter unit. + var btype = (declaration.SclBType ?? "").Trim().ToUpperInvariant(); + var mms = (declaration.MmsType ?? "").Trim().ToLowerInvariant(); + var isInteger = btype is "INT8" or "INT16" or "INT32" or "INT64" or + "INT8U" or "INT16U" or "INT32U" or "INT64U" or "INT24U" || + mms is "integer" or "unsigned"; + if (!isInteger || + btype is "ENUM" or "DBPOS" or "DPC" || + mms is "bit-string" or "boolean" or "enumerated") + return false; + return TryVerifyUnitPair(owner + ".units", out unit, + out provenance, out var fresh) && fresh; + } + /// Exact standard enum registry, never infer units from DO names. public static string RenderCfValue(string reference, string fc, string raw) { From 1b28a5e77b00ee316eabb72055d379064f82516e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:25:04 +0700 Subject: [PATCH 063/184] fix(P0): shared Browser detail projection formats CF-proven numeric XCBR status counters --- MainWindow.IedBrowser.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 06a251695..ffd88be59 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -902,6 +902,14 @@ private void RefreshIedBrowserVisibleValues() row.EngineeringUnitReference.Length == 0 ? row.ValueReference : row.EngineeringUnitReference, row.FunctionalConstraint)); + // A typed integer ST counter can carry a model-verified CF + // engineering unit. All enum/DPC/boolean ST remain untouched. + else if (row.EvidenceField.Length == 0 && + cfEvidence.TryFormatVerifiedNumericStatusUnit( + observedReference, row.FunctionalConstraint, + out var statusUnit, out _)) + display = IedBrowserEngineeringValuePresentation.Format( + display, statusUnit); // An expanded MX ang leaf is in degrees, not a fabricated pair. // cVal parent handles coherent phasor composition separately. else if (row.EvidenceField.Length == 0 && From d51e1fc0019a76f2841213ab9510fbbb2a96d147 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:25:39 +0700 Subject: [PATCH 064/184] =?UTF-8?q?test(P0):=20XCBR=20numeric=20ST=20units?= =?UTF-8?q?=20kA=C2=B2t/s,=20exact=20FC=20and=20stale/enum=20exclusions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../P0XcbrNumericCounterUnitTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs diff --git a/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs b/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs new file mode 100644 index 000000000..cac9d910f --- /dev/null +++ b/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs @@ -0,0 +1,110 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0XcbrNumericCounterUnitTests +{ + private static LiveIedDataObjectModel Counter(string name, string type = "INT32U") + { + var owner = "LD/XCBR1." + name; + LiveIedDataAttributeModel Attr(string path, string fc, string btype) => new() + { + ObjectReference = owner, AttributePath = name + "." + path, + MmsReference = "LD/XCBR1$" + fc + "$" + name + "$" + + path.Replace('.', '$'), + FunctionalConstraint = fc, SclBType = btype + }; + return new LiveIedDataObjectModel + { + Reference = owner, Name = name, InferredCdc = "INS", + Attributes = + [ + Attr("stVal", "ST", type), + Attr("units.SIUnit", "CF", "Enum"), + Attr("units.multiplier", "CF", "Enum") + ] + }; + } + + private static LiveIedModelDiscoveryDocument Model( + params LiveIedDataObjectModel[] dataObjects) + => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "XCBR1", LnClass = "XCBR", DataObjects = dataObjects + }] + }] + }; + + private static void PublishUnit( + IedBrowserLiveValues values, long association, + string owner, string unitOrd, string multiplier) + { + Assert.True(values.PublishForAssociation(association, + owner + ".units.SIUnit", "CF", unitOrd, "", "", "MMS Read")); + Assert.True(values.PublishForAssociation(association, + owner + ".units.multiplier", "CF", multiplier, "", "", "MMS Read")); + } + + [Fact] + public void Xcbr02_ExactIntegerCounterCfSupportsAmperesSquaredTimeAndSeconds() + { + var model = Model(Counter("SumSwARs"), Counter("SumSwATmms"), + Counter("ThAlSwA")); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + PublishUnit(ledger, association, "LD/XCBR1.SumSwARs", "70", "3"); + PublishUnit(ledger, association, "LD/XCBR1.SumSwATmms", "4", "0"); + PublishUnit(ledger, association, "LD/XCBR1.ThAlSwA", "70", "3"); + var cf = new IedBrowserCfEvidencePresentation(ledger, model, null); + + foreach (var (name, expected) in new[] + { + ("SumSwARs", "0 kA²t"), + ("SumSwATmms", "0 s"), + ("ThAlSwA", "0 kA²t") + }) + { + Assert.True(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1." + name + ".stVal", "ST", out var unit, out var proof)); + Assert.NotEmpty(proof); + Assert.Equal(expected, + IedBrowserEngineeringValuePresentation.Format("0", unit)); + } + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.SumSwARs.stVal", "MX", out _, out _)); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.Other.stVal", "ST", out _, out _)); + } + + [Fact] + public void Xcbr03_UntypedEnumAndMissingOrStaleCfMustNotGainCounterUnit() + { + var model = Model(Counter("SumSwARs"), Counter("CBOpCap", "Enum"), + Counter("Missing"), Counter("BooleanObject", "BOOLEAN")); + var ledger = new IedBrowserLiveValues(); + var oldAssociation = ledger.BeginAssociation(); + PublishUnit(ledger, oldAssociation, "LD/XCBR1.SumSwARs", "70", "3"); + PublishUnit(ledger, oldAssociation, "LD/XCBR1.CBOpCap", "70", "3"); + PublishUnit(ledger, oldAssociation, "LD/XCBR1.BooleanObject", "70", "3"); + var cf = new IedBrowserCfEvidencePresentation(ledger, model, null); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.CBOpCap.stVal", "ST", out _, out _)); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.BooleanObject.stVal", "ST", out _, out _)); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.Missing.stVal", "ST", out _, out _)); + + ledger.BeginAssociation(); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + "LD/XCBR1.SumSwARs.stVal", "ST", out _, out _)); + Assert.False(ledger.PublishForAssociation(oldAssociation, + "LD/XCBR1.SumSwARs.units.SIUnit", "CF", "70", + "", "", "MMS Read")); + } +} From 4b5b9b5679c22bcca6d08bb17ce234ae1f5e3751 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:28:03 +0700 Subject: [PATCH 065/184] fix(P0): one semantic unit resolution for parent, leaf, DataSet, Report and SG rows --- .../IedBrowserEngineeringValuePresentation.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index f92cef90f..cf83ff687 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -142,6 +142,30 @@ private static string AngleSymbol(string? unit) return string.Equals(unit, "rad", StringComparison.Ordinal) ? "rad" : "°"; } + /// + /// One pure semantic unit decision for all Browser pages. The container + /// and its observed leaf must yield the same engineering unit. Exact + /// typed integer ST is allowed ONLY by live paired owner CF evidence; + /// status enums, q/t, and unverified counter metadata remain unitless. + /// + public static string ResolveObservedUnit( + IReadOnlyDictionary units, + IedBrowserCfEvidencePresentation cfEvidence, + string observedReference, string engineeringReference, + string fc, bool isEngineeringValue) + { + var unit = isEngineeringValue ? ResolveUnit(units, engineeringReference, fc) : ""; + var observed = IedBrowserLiveValues.CanonicalReference(observedReference); + if (unit.Length == 0 && string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) && + observed.EndsWith(".stVal", StringComparison.Ordinal) && + cfEvidence.TryFormatVerifiedNumericStatusUnit(observed, fc, out var numeric, out _)) + unit = numeric; + if (unit.Length == 0 && string.Equals(fc, "MX", StringComparison.OrdinalIgnoreCase) && + observed.EndsWith(".ang.f", StringComparison.Ordinal)) + unit = ResolveVectorAngleUnit(units, observed, fc); + return unit; + } + public static string Format(string raw, string unit) { if (string.IsNullOrWhiteSpace(unit) || string.IsNullOrWhiteSpace(raw)) From 9724de6f64c027b7453010e7ae21032adbe84467 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:28:22 +0700 Subject: [PATCH 066/184] fix(P0): apply identical typed SSOT engineering unit to XCBR parent and leaf --- MainWindow.IedBrowser.cs | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index ffd88be59..1b4cf4f54 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -895,29 +895,16 @@ private void RefreshIedBrowserVisibleValues() observedReference, row.FunctionalConstraint, display, _iedBrowserValueSchemaIndex); } - if (row.IsEngineeringValue && row.EvidenceField.Length == 0) + // One dimensionally typed projector across Data Model, DataSet, + // Report and Setting Group detail rows. DO parent and direct leaf + // reuse the SAME observed reference + owner CF unit decision. + if (row.EvidenceField.Length == 0) display = IedBrowserEngineeringValuePresentation.Format( - display, IedBrowserEngineeringValuePresentation.ResolveUnit( - engineeringUnits, + display, IedBrowserEngineeringValuePresentation.ResolveObservedUnit( + engineeringUnits, cfEvidence, observedReference, row.EngineeringUnitReference.Length == 0 ? row.ValueReference : row.EngineeringUnitReference, - row.FunctionalConstraint)); - // A typed integer ST counter can carry a model-verified CF - // engineering unit. All enum/DPC/boolean ST remain untouched. - else if (row.EvidenceField.Length == 0 && - cfEvidence.TryFormatVerifiedNumericStatusUnit( - observedReference, row.FunctionalConstraint, - out var statusUnit, out _)) - display = IedBrowserEngineeringValuePresentation.Format( - display, statusUnit); - // An expanded MX ang leaf is in degrees, not a fabricated pair. - // cVal parent handles coherent phasor composition separately. - else if (row.EvidenceField.Length == 0 && - observedReference.EndsWith(".ang.f", StringComparison.Ordinal) && - row.FunctionalConstraint == "MX") - display = IedBrowserEngineeringValuePresentation.Format( - display, IedBrowserEngineeringValuePresentation.ResolveVectorAngleUnit( - engineeringUnits, observedReference, "MX")); + row.FunctionalConstraint, row.IsEngineeringValue)); row.Value = live.IsStale ? "Offline · " + display : display; } UpdateIedBrowserGooseDetails(); From 5be41027f7e8175e050292498cd7349d9d4d8740 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:28:35 +0700 Subject: [PATCH 067/184] test(P0): XCBR parent and leaf share model-proven engineering unit --- tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs b/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs index cac9d910f..502f7a420 100644 --- a/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs +++ b/tests/ARSAS.Tests/P0XcbrNumericCounterUnitTests.cs @@ -75,6 +75,16 @@ public void Xcbr02_ExactIntegerCounterCfSupportsAmperesSquaredTimeAndSeconds() Assert.NotEmpty(proof); Assert.Equal(expected, IedBrowserEngineeringValuePresentation.Format("0", unit)); + var reference = "LD/XCBR1." + name + ".stVal"; + var units = cf.BuildVerifiedUnits([], model); + // Same unit for a DO parent, scalar leaf, or a DataSet/Report + // row resolving that exact canonical ST stVal. + var parentUnit = IedBrowserEngineeringValuePresentation.ResolveObservedUnit( + units, cf, reference, reference, "ST", isEngineeringValue: true); + var leafUnit = IedBrowserEngineeringValuePresentation.ResolveObservedUnit( + units, cf, reference, reference, "ST", isEngineeringValue: false); + Assert.Equal(unit, parentUnit); + Assert.Equal(unit, leafUnit); } Assert.False(cf.TryFormatVerifiedNumericStatusUnit( "LD/XCBR1.SumSwARs.stVal", "MX", out _, out _)); From 2d02fab9abc153b94184fe2783de8abbe536684a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:33:01 +0700 Subject: [PATCH 068/184] perf(P0): index typed numeric ST declarations once per live model; reject duplicates --- Models/IedBrowserCfEvidencePresentation.cs | 71 +++++++++++++--------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/Models/IedBrowserCfEvidencePresentation.cs b/Models/IedBrowserCfEvidencePresentation.cs index 7cb66fbcb..ab68af794 100644 --- a/Models/IedBrowserCfEvidencePresentation.cs +++ b/Models/IedBrowserCfEvidencePresentation.cs @@ -17,7 +17,8 @@ public sealed class IedBrowserCfEvidencePresentation // indexes contain declarations and historical evidence only, never // mutable current Report/Read observations. private sealed record SchemaIndex( - HashSet DeclaredCf, string[] UnitOwners, string[] SettingOwners); + HashSet DeclaredCf, string[] UnitOwners, string[] SettingOwners, + HashSet NumericStatusLeaves); private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< LiveIedModelDiscoveryDocument, SchemaIndex> SchemaCache = new(); private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< @@ -25,6 +26,7 @@ private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< private readonly LiveIedModelDiscoveryDocument? _model; private readonly string[] _unitOwners; private readonly string[] _settingOwners; + private readonly HashSet _numericStatusLeaves = new(StringComparer.Ordinal); private readonly Dictionary _snapshot = new(StringComparer.Ordinal); private readonly HashSet _declaredCf = new(StringComparer.Ordinal); public bool HasSnapshot { get; } @@ -41,6 +43,7 @@ public IedBrowserCfEvidencePresentation( _declaredCf = schema.DeclaredCf; _unitOwners = schema.UnitOwners; _settingOwners = schema.SettingOwners; + _numericStatusLeaves = schema.NumericStatusLeaves; } else { @@ -60,11 +63,31 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) var declared = new HashSet(StringComparer.Ordinal); var owners = new HashSet(StringComparer.Ordinal); var settingOwners = new HashSet(StringComparer.Ordinal); - foreach (var a in model.LogicalDevices.SelectMany(d => d.LogicalNodes) - .SelectMany(n => n.DataObjects).SelectMany(o => o.Attributes)) + var objects = model.LogicalDevices.SelectMany(d => d.LogicalNodes) + .SelectMany(n => n.DataObjects).ToArray(); + var exactOwners = objects.Select(o => + IedBrowserLiveValues.CanonicalReference(o.Reference)) + .Where(o => o.Length > 0).ToHashSet(StringComparer.Ordinal); + var numericStatusCounts = new Dictionary(StringComparer.Ordinal); + foreach (var a in objects.SelectMany(o => o.Attributes)) { var reference = IedBrowserLiveValues.CanonicalReference(a.MmsReference); if (reference.Length == 0) continue; + if (string.Equals(a.FunctionalConstraint, "ST", + StringComparison.OrdinalIgnoreCase) && + reference.EndsWith(".stVal", StringComparison.Ordinal)) + { + var owner = reference[..^".stVal".Length]; + if (exactOwners.Contains(owner) && + string.Equals(IedBrowserLiveValues.CanonicalReference(a.ObjectReference), + owner, StringComparison.Ordinal) && + IsTypedIntegerStatus(a)) + { + var key = Key(reference, "ST"); + numericStatusCounts[key] = + numericStatusCounts.GetValueOrDefault(key) + 1; + } + } if (string.Equals(a.FunctionalConstraint, "CF", StringComparison.OrdinalIgnoreCase)) declared.Add(Key(reference, "CF")); @@ -89,7 +112,9 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) if (suffix.Length != 0) owners.Add(reference[..^suffix.Length]); } return new SchemaIndex(declared, owners.OrderBy(s => s, StringComparer.Ordinal) - .ToArray(), settingOwners.OrderBy(s => s, StringComparer.Ordinal).ToArray()); + .ToArray(), settingOwners.OrderBy(s => s, StringComparer.Ordinal).ToArray(), + numericStatusCounts.Where(x => x.Value == 1) + .Select(x => x.Key).ToHashSet(StringComparer.Ordinal)); } private static Dictionary BuildSnapshot(LiveIedCanonicalModel canonical) @@ -166,6 +191,18 @@ public bool TryFormatUnitsParent(string reference, string fc, return true; } + private static bool IsTypedIntegerStatus(LiveIedDataAttributeModel declaration) + { + // A typed status counter, not an arbitrary numeric enumeration. + var btype = (declaration.SclBType ?? "").Trim().ToUpperInvariant(); + var mms = (declaration.MmsType ?? "").Trim().ToLowerInvariant(); + var isInteger = btype is "INT8" or "INT16" or "INT32" or "INT64" or + "INT8U" or "INT16U" or "INT32U" or "INT64U" or "INT24U" || + mms is "integer" or "unsigned"; + return isInteger && btype is not ("ENUM" or "DBPOS" or "DPC") && + mms is not ("bit-string" or "boolean" or "enumerated"); + } + private bool TryVerifyUnitPair(string owner, out string unit, out string provenance, out bool fresh) { @@ -213,31 +250,7 @@ public bool TryFormatVerifiedNumericStatusUnit( var path = IedBrowserLiveValues.CanonicalReference(reference); if (!path.EndsWith(".stVal", StringComparison.Ordinal)) return false; var owner = path[..^".stVal".Length]; - var matches = _model.LogicalDevices.SelectMany(d => d.LogicalNodes) - .SelectMany(n => n.DataObjects) - .Where(o => string.Equals( - IedBrowserLiveValues.CanonicalReference(o.Reference), - owner, StringComparison.Ordinal)) - .Take(2).ToArray(); - if (matches.Length != 1) return false; - var attributes = matches[0].Attributes.Where(a => - string.Equals(IedBrowserLiveValues.CanonicalReference(a.MmsReference), - path, StringComparison.Ordinal) && - string.Equals(a.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase)) - .Take(2).ToArray(); - if (attributes.Length != 1) return false; - var declaration = attributes[0]; - // Only IEC integer primitives: DPC bitstrings, enumerations, quality - // bitstrings, and bools must never inherit a numeric counter unit. - var btype = (declaration.SclBType ?? "").Trim().ToUpperInvariant(); - var mms = (declaration.MmsType ?? "").Trim().ToLowerInvariant(); - var isInteger = btype is "INT8" or "INT16" or "INT32" or "INT64" or - "INT8U" or "INT16U" or "INT32U" or "INT64U" or "INT24U" || - mms is "integer" or "unsigned"; - if (!isInteger || - btype is "ENUM" or "DBPOS" or "DPC" || - mms is "bit-string" or "boolean" or "enumerated") - return false; + if (!_numericStatusLeaves.Contains(Key(path, "ST"))) return false; return TryVerifyUnitPair(owner + ".units", out unit, out provenance, out var fresh) && fresh; } From aa3e566357125630c02c6f7dce7d235a6e5454ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:33:42 +0700 Subject: [PATCH 069/184] perf(P0): weak model-keyed typed Report layout index; no per-frame model scan --- .../IedBrowserTypedWireCompanionLayout.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Services/IedBrowserTypedWireCompanionLayout.cs b/Services/IedBrowserTypedWireCompanionLayout.cs index 5a9f6d7d0..c4b802409 100644 --- a/Services/IedBrowserTypedWireCompanionLayout.cs +++ b/Services/IedBrowserTypedWireCompanionLayout.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using AR.Iec61850.Discovery; using AR.Iec61850.Mms; @@ -15,16 +17,31 @@ public static class IedBrowserTypedWireCompanionLayout public sealed record Route( string Reference, string Kind, IReadOnlyList ChildIndices); + // Cache only the model's immutable declaration routes, NEVER Report/Read + // evidence. Weak ownership evicts layouts when the IED model is replaced. + private static readonly ConditionalWeakTable>> RouteCache = new(); + public static IReadOnlyList Resolve( LiveIedModelDiscoveryDocument? model, string carrier, string fc) { if (model is null || string.IsNullOrWhiteSpace(fc)) return []; var root = ArIED61850Tester.Models.IedBrowserLiveValues.CanonicalReference(carrier); + var normalizedFc = fc.Trim().ToUpperInvariant(); + var cache = RouteCache.GetValue(model, _ => + new ConcurrentDictionary>(StringComparer.Ordinal)); + return cache.GetOrAdd(root + "|" + normalizedFc, + _ => BuildVerifiedRoutes(model, root, normalizedFc)); + } + + private static IReadOnlyList BuildVerifiedRoutes( + LiveIedModelDiscoveryDocument model, string root, string fc) + { var slash = root.IndexOf('/'); if (slash <= 0 || root.Length <= slash + 1) return []; var carrierDepth = root[(slash + 1)..].Split('.').Length; if (carrierDepth < 2 || carrierDepth > 12) return []; - var selectedFc = fc.Trim().ToUpperInvariant(); + var selectedFc = fc; var matches = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) .SelectMany(ln => ln.DataObjects).SelectMany(obj => obj.Attributes) .Where(attr => string.Equals(attr.FunctionalConstraint, From 9f0cbf18100de3f21e697d6b65bc16c5f8433fb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:51:25 +0700 Subject: [PATCH 070/184] feat(P0): proof-bound SG EnumType symbols from SHA-verified CID and exact typed MMS SG --- Models/IedBrowserSclEnumEvidence.cs | 153 ++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 Models/IedBrowserSclEnumEvidence.cs diff --git a/Models/IedBrowserSclEnumEvidence.cs b/Models/IedBrowserSclEnumEvidence.cs new file mode 100644 index 000000000..44dfada5a --- /dev/null +++ b/Models/IedBrowserSclEnumEvidence.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Models; + +/// +/// Read-only SCL EnumType declaration authority. Symbols are accepted only from +/// the original SHA-256-verified CID/SCL source, its exact selected IED's +/// DataTypeTemplates binding and an exact online typed SG attribute identity. +/// No ordinal-to-symbol mappings are shared between unrelated enum types. +/// This contains declarative metadata ONLY, never MMS observations. +/// +public sealed class IedBrowserSclEnumEvidence +{ + private sealed record BoundEnum( + string TypeId, IReadOnlyDictionary Symbols); + private readonly IReadOnlyDictionary _bindings; + public string SourceSha256 { get; } + public int BoundAttributeCount => _bindings.Count; + + private IedBrowserSclEnumEvidence( + string sha256, IReadOnlyDictionary bindings) + { + SourceSha256 = sha256; + _bindings = bindings; + } + + public static IedBrowserSclEnumEvidence Build( + VerifiedSclSource source, SclIedWorkspace selectedIed, + LiveIedModelDiscoveryDocument? onlineModel) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selectedIed); + var bound = new Dictionary(StringComparer.Ordinal); + // A source with no unique selected IED is not a type authority. + var xml = XDocument.Parse(source.Xml, LoadOptions.None); + var root = xml.Root; + if (root is null || string.IsNullOrWhiteSpace(selectedIed.IedName) || + onlineModel is null) + return new IedBrowserSclEnumEvidence(source.Sha256, bound); + var ieds = root.Elements().Where(x => NameIs(x, "IED") && + string.Equals(Attr(x, "name"), selectedIed.IedName, + StringComparison.Ordinal)).Take(2).ToArray(); + if (ieds.Length != 1) + return new IedBrowserSclEnumEvidence(source.Sha256, bound); + + // Exact ID, no first-wins on duplicate conflicting definitions. + var enumGroups = root.Descendants().Where(x => NameIs(x, "EnumType")) + .GroupBy(x => Attr(x, "id"), StringComparer.Ordinal) + .Where(g => !string.IsNullOrWhiteSpace(g.Key) && g.Count() == 1) + .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); + var declarations = selectedIed.DesignModel.LogicalDevices + .SelectMany(d => d.LogicalNodes) + .SelectMany(n => n.DataObjects) + .SelectMany(o => o.Attributes) + .Where(a => string.Equals(a.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase) && + string.Equals(a.SclBType, "Enum", + StringComparison.OrdinalIgnoreCase) && + string.Equals(a.Source, "SCL.DataTypeTemplates", + StringComparison.Ordinal) && + a.MmsTypeSignature.StartsWith("Enum:", StringComparison.Ordinal)) + .GroupBy(a => Key(a.MmsReference, a.FunctionalConstraint), + StringComparer.Ordinal) + .Where(g => g.Count() == 1).Select(g => g.Single()).ToArray(); + + // The selected IED's online type proof must be unique and enumerated. + var live = onlineModel.LogicalDevices.SelectMany(d => d.LogicalNodes) + .SelectMany(n => n.DataObjects).SelectMany(o => o.Attributes) + .Where(a => string.Equals(a.FunctionalConstraint, "SG", + StringComparison.OrdinalIgnoreCase)) + .GroupBy(a => Key(a.MmsReference, a.FunctionalConstraint), + StringComparer.Ordinal) + .Where(g => g.Count() == 1 && + g.Single().TypeDiscoveryStatus.Equals("Exact", + StringComparison.OrdinalIgnoreCase) && + (g.Single().MmsType.Equals("enumerated", StringComparison.OrdinalIgnoreCase) || + g.Single().MmsType.Equals("enum", StringComparison.OrdinalIgnoreCase) || + g.Single().SclBType.Equals("Enum", StringComparison.OrdinalIgnoreCase))) + .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); + + foreach (var attr in declarations) + { + var typeId = attr.MmsTypeSignature["Enum:".Length..]; + var key = Key(attr.MmsReference, attr.FunctionalConstraint); + if (!live.ContainsKey(key) || + !enumGroups.TryGetValue(typeId, out var enumType) || + !TrySymbols(enumType, out var symbols)) + continue; + bound.Add(key, new BoundEnum(typeId, symbols)); + } + return new IedBrowserSclEnumEvidence(source.Sha256, bound); + } + + public bool TryFormat(string reference, string fc, string raw, + out string display, out string evidence) + { + display = raw; + evidence = "No exact live SG EnumType declaration in the verified SCL source."; + if (!string.Equals(fc, "SG", StringComparison.OrdinalIgnoreCase) || + !_bindings.TryGetValue(Key(reference, fc), out var bound)) + return false; + var ordinalText = (raw ?? "").Trim(); + if (ordinalText.StartsWith("value", StringComparison.OrdinalIgnoreCase)) + ordinalText = ordinalText["value".Length..]; + if (!long.TryParse(ordinalText, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var ordinal)) + { + evidence = "SG enum has an unrecognized raw MMS ordinal."; + return false; + } + if (!bound.Symbols.TryGetValue(ordinal, out var symbol)) + { + evidence = $"SCL EnumType {bound.TypeId} has no ordinal {ordinal}; raw preserved."; + return false; + } + display = symbol; + evidence = $"Verified SCL EnumType {bound.TypeId}, ord={ordinal}; source SHA-256 {SourceSha256}. Raw MMS ordinal preserved in SSOT."; + return true; + } + + private static bool TrySymbols(XElement definition, + out IReadOnlyDictionary symbols) + { + symbols = new Dictionary(); + var values = definition.Elements().Where(x => NameIs(x, "EnumVal")) + .Take(257).ToArray(); + if (values.Length is < 1 or > 256) return false; + var decoded = new Dictionary(); + foreach (var value in values) + { + var name = value.Value.Trim(); + if (name.Length is < 1 or > 128 || + !long.TryParse(Attr(value, "ord"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var ord) || + !decoded.TryAdd(ord, name)) + return false; + } + symbols = decoded; + return true; + } + + private static string Key(string reference, string fc) + => IedBrowserLiveValues.CanonicalReference(reference) + "|" + + (fc ?? "").Trim().ToUpperInvariant(); + private static bool NameIs(XElement element, string localName) + => string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal); + private static string Attr(XElement element, string name) + => (string?)element.Attribute(name) ?? ""; +} From 7b49c4a6658ee8edf889244ea77f490fa0490fe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:52:34 +0700 Subject: [PATCH 071/184] test(P0): exact source SCL EnumType SG mapping, type/FC mismatch, duplicate IDs, SSOT raw and association fencing --- .../ARSAS.Tests/P0VerifiedSgEnumTypeTests.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/ARSAS.Tests/P0VerifiedSgEnumTypeTests.cs diff --git a/tests/ARSAS.Tests/P0VerifiedSgEnumTypeTests.cs b/tests/ARSAS.Tests/P0VerifiedSgEnumTypeTests.cs new file mode 100644 index 000000000..36e24bed8 --- /dev/null +++ b/tests/ARSAS.Tests/P0VerifiedSgEnumTypeTests.cs @@ -0,0 +1,163 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P0VerifiedSgEnumTypeTests +{ + private const string Owner = "BCUGELD0/SGgndPIOC1.InMagTyp"; + private const string Raw = Owner + ".setVal"; + private const string Scl = """ + + + + + + + + + + + + + + + + + + + + + phasor + amplitude + + + not-phasor + + + + """; + + private static LiveIedModelDiscoveryDocument Online(string type = "enumerated", + string fc = "SG", bool exact = true) + { + LiveIedDataAttributeModel Attribute(string reference) => new() + { + ObjectReference = reference[..reference.LastIndexOf('.')], + AttributePath = reference[(reference.IndexOf('/') + 1)..], + MmsReference = reference, + FunctionalConstraint = fc, + SclBType = "Enum", + MmsType = type, + TypeDiscoveryStatus = exact ? "Exact" : "NotRead", + TypeSource = exact ? "GetVariableAccessAttributesLogicalNodeTree" + : "NameListHeuristic" + }; + return new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "BCUGELD0", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "SGgndPIOC1", LnClass = "PIOC", + DataObjects = [new LiveIedDataObjectModel + { + Name = "InMagTyp", Reference = Owner, + Attributes = [Attribute(Raw)] + },new LiveIedDataObjectModel + { + Name = "AnotherSetting", Reference = "BCUGELD0/SGgndPIOC1.AnotherSetting", + Attributes = [Attribute("BCUGELD0/SGgndPIOC1.AnotherSetting.setVal")] + }] + }] + }] + }; + } + + private static IedBrowserSclEnumEvidence Resolve(string xml = Scl, + LiveIedModelDiscoveryDocument? online = null, + string iedName = "BCUGE") + { + var design = SclLiveModelProjectionBuilder.Build(XDocument.Parse(xml)); + var workspace = new SclIedWorkspace + { + IedName = iedName, DesignModel = design + }; + return IedBrowserSclEnumEvidence.Build( + new VerifiedSclSource("trusted.cid", new string('a', 64), xml), + workspace, online ?? Online()); + } + + [Fact] + public void Sg01_ExactVerifiedSclEnumTypeMapsOrdinalOnlyForItsOwnSetting() + { + var types = Resolve(); + Assert.Equal(2, types.BoundAttributeCount); + Assert.True(types.TryFormat(Raw, "SG", "0", + out var display, out var evidence)); + Assert.Equal("phasor", display); + Assert.Contains("BCUGEInMagKind", evidence); + Assert.Contains("ord=0", evidence); + Assert.True(types.TryFormat(Raw, "SG", "value1", + out display, out _)); + Assert.Equal("amplitude", display); + Assert.True(types.TryFormat("BCUGELD0/SGgndPIOC1.AnotherSetting.setVal", + "SG", "0", out display, out _)); + Assert.Equal("not-phasor", display); + Assert.False(types.TryFormat(Raw, "SE", "0", out display, out _)); + Assert.Equal("0", display); + } + + [Fact] + public void Sg02_UnknownOrdinalAndUnverifiedMmsTypeRemainRaw() + { + var types = Resolve(); + Assert.False(types.TryFormat(Raw, "SG", "99", + out var display, out var reason)); + Assert.Equal("99", display); + Assert.Contains("no ordinal 99", reason); + Assert.Equal(0, Resolve(online: Online(exact: false)).BoundAttributeCount); + Assert.Equal(0, Resolve(online: Online(type: "boolean")).BoundAttributeCount); + Assert.Equal(0, Resolve(online: Online(fc: "SE")).BoundAttributeCount); + Assert.Equal(0, Resolve(iedName: "WRONG").BoundAttributeCount); + } + + [Fact] + public void Sg03_DuplicatedEnumTypeIdMustFailClosed() + { + var duplicate = Scl.Replace("", + """ + wrong + + """); + var types = Resolve(duplicate); + Assert.False(types.TryFormat(Raw, "SG", "0", + out var raw, out _)); + Assert.Equal("0", raw); + Assert.True(types.TryFormat("BCUGELD0/SGgndPIOC1.AnotherSetting.setVal", + "SG", "0", out var other, out _)); + Assert.Equal("not-phasor", other); + } + + [Fact] + public void Sg04_LiveOrdinalRemainsUntouchedInAssociationBoundCanonicalLedger() + { + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(association, Raw, "SG", + "0", "", "", "MMS Read")); + var types = Resolve(); + Assert.True(ledger.TryGet(Raw, "SG", out var evidence)); + Assert.Equal("0", evidence.Value); + Assert.True(types.TryFormat(Raw, "SG", evidence.Value, out var display, out _)); + Assert.Equal("phasor", display); + Assert.Equal("0", evidence.Value); + ledger.BeginAssociation(); + Assert.False(ledger.TryGet(Raw, "SG", out _)); + } +} From f6cbe4cb2a0aaae257316be277c773fc455ea93d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:53:21 +0700 Subject: [PATCH 072/184] fix(P0): render vendor SG via SHA-verified exact EnumType; association and source fenced, raw SSOT unchanged --- MainWindow.IedBrowser.cs | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 1b4cf4f54..36dcadd86 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -32,6 +32,14 @@ public partial class MainWindow private Iec61850MonitorDevice? _iedBrowserCachedDevice; private LiveIedModelDiscoveryDocument? _iedBrowserCachedModel; private IedBrowserSchemaIndex? _iedBrowserValueSchemaIndex; + // Immutable EnumType declarations, never a second live-value store. + // A SHA-verified SCL source and exact online SG MMS type are both required. + private IedBrowserSclEnumEvidence? _iedBrowserSclEnumEvidence; + private Iec61850MonitorDevice? _iedBrowserSclEnumDevice; + private LiveIedModelDiscoveryDocument? _iedBrowserSclEnumLiveModel; + private string _iedBrowserSclEnumSourceSha = ""; + private int _iedBrowserSclEnumLoadTicket; + private string _iedBrowserSclEnumAbsenceReason = ""; private MmsRcbAvailabilityResult? _iedBrowserCachedSnapshot; // Metadata-only finite admission: attempts never cache an observed value. // The device.LiveValues ledger remains the sole MMS Report/Read authority. @@ -894,6 +902,29 @@ private void RefreshIedBrowserVisibleValues() display = IedBrowserStandardStatusPresentation.Format( observedReference, row.FunctionalConstraint, display, _iedBrowserValueSchemaIndex); + // The exact verified SCL EnumType is the only authority + // for vendor SG ordinal symbols. SSOT retains raw MMS '0'. + if (row.FunctionalConstraint == "SG" && + observedReference.EndsWith(".setVal", StringComparison.Ordinal)) + { + if (_iedBrowserSclEnumEvidence?.TryFormat( + observedReference, "SG", display, + out var symbol, out var enumProof) == true) + { + display = symbol; + row.ObservationTooltip += " · " + enumProof; + } + else + { + row.ObservationTooltip += + " · SG enum remains raw: " + + (_iedBrowserSclEnumEvidence is null + ? _iedBrowserSclEnumAbsenceReason.Length == 0 + ? "verified SCL EnumType is not loaded" + : _iedBrowserSclEnumAbsenceReason + : "no exact matched verified EnumType or ordinal"); + } + } } // One dimensionally typed projector across Data Model, DataSet, // Report and Setting Group detail rows. DO parent and direct leaf @@ -1453,6 +1484,90 @@ or nameof(Iec61850MonitorDevice.IsMonitoring))) RefreshIedBrowser(); } + /// + /// Metadata-only, one-shot asynchronous EnumType source verification. + /// This never reads process values, changes the SG, or creates a second + /// mutable observation cache. Stale device/model/source flights are fenced. + /// + private void QueueIedBrowserVerifiedSclEnums(Iec61850MonitorDevice device) + { + var design = device.SclWorkspace; + var model = device.LiveDiscoveryModel; + var sourcePath = device.SclSourcePath; + var sourceSha = device.SclSourceSha256; + if (design is null || model is null || !device.IsConnected || + string.IsNullOrWhiteSpace(sourcePath) || + string.IsNullOrWhiteSpace(sourceSha)) + { + if (!ReferenceEquals(_iedBrowserSclEnumDevice, device) || + !ReferenceEquals(_iedBrowserSclEnumLiveModel, model)) + { + _iedBrowserSclEnumLoadTicket++; + _iedBrowserSclEnumEvidence = null; + _iedBrowserSclEnumDevice = device; + _iedBrowserSclEnumLiveModel = model; + _iedBrowserSclEnumSourceSha = ""; + _iedBrowserSclEnumAbsenceReason = + "No matching verified SCL source and online SG type."; + } + return; + } + + var signature = sourcePath + "|" + sourceSha; + if (ReferenceEquals(_iedBrowserSclEnumDevice, device) && + ReferenceEquals(_iedBrowserSclEnumLiveModel, model) && + string.Equals(_iedBrowserSclEnumSourceSha, signature, + StringComparison.Ordinal)) + return; + _iedBrowserSclEnumEvidence = null; + _iedBrowserSclEnumDevice = device; + _iedBrowserSclEnumLiveModel = model; + _iedBrowserSclEnumSourceSha = signature; + _iedBrowserSclEnumAbsenceReason = "Verifying the matching SCL EnumType."; + var ticket = ++_iedBrowserSclEnumLoadTicket; + _ = LoadIedBrowserVerifiedSclEnumsAsync( + device, model, design, sourcePath, sourceSha, signature, ticket); + } + + private async Task LoadIedBrowserVerifiedSclEnumsAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument liveModel, + AR.Iec61850.Scl.Workspace.SclIedWorkspace design, + string sourcePath, string sourceSha, string signature, int ticket) + { + try + { + var verified = await VerifiedSclSourceLoader.LoadAsync( + sourcePath, sourceSha, _applicationCancellation.Token); + var enums = await Task.Run(() => + IedBrowserSclEnumEvidence.Build(verified, design, liveModel), + _applicationCancellation.Token); + if (ticket != _iedBrowserSclEnumLoadTicket || + !ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(device.LiveDiscoveryModel, liveModel) || + !ReferenceEquals(device.SclWorkspace, design) || + !string.Equals(_iedBrowserSclEnumSourceSha, signature, + StringComparison.Ordinal) || !device.IsConnected) + return; + _iedBrowserSclEnumEvidence = enums; + _iedBrowserSclEnumAbsenceReason = enums.BoundAttributeCount > 0 + ? "" : "SCL has no exact online-verified SG EnumType bindings."; + _iedBrowserLastLiveVersion = -1; + RefreshIedBrowserVisibleValues(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (ticket != _iedBrowserSclEnumLoadTicket || + !ReferenceEquals(SelectedDevice, device)) + return; + _iedBrowserSclEnumEvidence = null; + _iedBrowserSclEnumAbsenceReason = + "Trusted SCL enum source rejected: " + ex.Message; + _iedBrowserLastLiveVersion = -1; + RefreshIedBrowserVisibleValues(); + } + } + private void RefreshIedBrowser() { if (IedBrowserTab == null || MainTabs.SelectedIndex != IedBrowserWorkspaceIndex) @@ -1496,6 +1611,8 @@ private void RefreshIedBrowser() $" · {model?.LogicalDevices.Count ?? 0} LD / {model?.DataSets.Count ?? 0} DataSets / " + $"{model?.ReportControls.Count ?? 0} RCB"; + QueueIedBrowserVerifiedSclEnums(device); + // Source SCL parsing and hierarchical projection are performed only when // device/model/check/source really changes, not on every IsBusy/connection notification. var sourcePath = device.SclSourcePath ?? string.Empty; From edf52ad3c8c1916ef74db6f4ce6f188c7bdaa500 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:54:23 +0700 Subject: [PATCH 073/184] fix(P0): isolate SCL SG enum bindings to selected IED logical devices, never other IEDs --- Models/IedBrowserSclEnumEvidence.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Models/IedBrowserSclEnumEvidence.cs b/Models/IedBrowserSclEnumEvidence.cs index 44dfada5a..080c49149 100644 --- a/Models/IedBrowserSclEnumEvidence.cs +++ b/Models/IedBrowserSclEnumEvidence.cs @@ -46,6 +46,13 @@ public static IedBrowserSclEnumEvidence Build( StringComparison.Ordinal)).Take(2).ToArray(); if (ieds.Length != 1) return new IedBrowserSclEnumEvidence(source.Sha256, bound); + // The SCL document can contain many IEDs. Only declared logical + // devices belonging to the exact selected IED may bind SG semantics. + var acceptedDomains = ieds[0].Descendants().Where(x => NameIs(x, "LDevice")) + .Select(x => selectedIed.IedName + Attr(x, "inst")) + .ToHashSet(StringComparer.Ordinal); + if (acceptedDomains.Count == 0) + return new IedBrowserSclEnumEvidence(source.Sha256, bound); // Exact ID, no first-wins on duplicate conflicting definitions. var enumGroups = root.Descendants().Where(x => NameIs(x, "EnumType")) @@ -53,6 +60,7 @@ public static IedBrowserSclEnumEvidence Build( .Where(g => !string.IsNullOrWhiteSpace(g.Key) && g.Count() == 1) .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); var declarations = selectedIed.DesignModel.LogicalDevices + .Where(d => acceptedDomains.Contains(d.MmsDomain)) .SelectMany(d => d.LogicalNodes) .SelectMany(n => n.DataObjects) .SelectMany(o => o.Attributes) From f52d51257534a64220a0a0b76e4062c47dfa9590 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:56:17 +0700 Subject: [PATCH 074/184] fix(P0): invalidate cached SG enum declaration on trusted SCL source file modification --- MainWindow.IedBrowser.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 36dcadd86..38ccb9260 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1513,7 +1513,17 @@ private void QueueIedBrowserVerifiedSclEnums(Iec61850MonitorDevice device) return; } - var signature = sourcePath + "|" + sourceSha; + // An on-disk edit invalidates previously verified declaration + // symbols even if the device and imported source SHA did not change. + // The async source loader then rejects any content/SHA mismatch. + long sourceStamp; + try { sourceStamp = System.IO.File.GetLastWriteTimeUtc(sourcePath).Ticks; } + catch (Exception ex) when (ex is System.IO.IOException or + UnauthorizedAccessException) + { + sourceStamp = -1; + } + var signature = sourcePath + "|" + sourceSha + "|" + sourceStamp; if (ReferenceEquals(_iedBrowserSclEnumDevice, device) && ReferenceEquals(_iedBrowserSclEnumLiveModel, model) && string.Equals(_iedBrowserSclEnumSourceSha, signature, From 199cc97827f4c2099f1cbda0974d442e4a479080 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:57:42 +0700 Subject: [PATCH 075/184] feat(P0): read-only exact-FC SSOT coverage metrics for CF, SG EnumType and typed q/t --- Models/IedBrowserAcceptanceMetrics.cs | 122 ++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 Models/IedBrowserAcceptanceMetrics.cs diff --git a/Models/IedBrowserAcceptanceMetrics.cs b/Models/IedBrowserAcceptanceMetrics.cs new file mode 100644 index 000000000..2877e3ca0 --- /dev/null +++ b/Models/IedBrowserAcceptanceMetrics.cs @@ -0,0 +1,122 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// On-demand read-only acceptance counts over the SAME association-fenced +/// canonical ledger. No network call, second value cache, synthetic DataSet +/// member or unproven inter-consumer parity claim. +/// +public sealed record IedBrowserAcceptanceMetrics( + int ReadableAttributes, + int FreshObserved, + int MissingOrDeferred, + int FailedReadSamples, + int CfPairOwners, + int CfPairsProven, + int SgEnumObserved, + int SgEnumDecoded, + int QualityObserved, + int QualityDecoded, + int TimestampObserved, + int TimestampDecoded, + int? ConsumerMismatchCount = null) +{ + public string Summary => + $"IEC SSOT · readable {ReadableAttributes:N0} · fresh {FreshObserved:N0} " + + $"· missing/deferred {MissingOrDeferred:N0} · failed read samples {FailedReadSamples:N0} " + + $"· CF {CfPairsProven}/{CfPairOwners} · SG enum {SgEnumDecoded}/{SgEnumObserved} " + + $"· q {QualityDecoded}/{QualityObserved} · t {TimestampDecoded}/{TimestampObserved} " + + "· consumer mismatch not measured"; + + public static IedBrowserAcceptanceMetrics Capture( + LiveIedModelDiscoveryDocument model, + IedBrowserLiveValues ledger, + IedBrowserSclEnumEvidence? enumEvidence = null, + IReadOnlyList? failures = null) + { + 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) + .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 fresh = 0; + var qObserved = 0; + var qDecoded = 0; + var tObserved = 0; + var tDecoded = 0; + var sgObserved = 0; + var sgDecoded = 0; + var missing = 0; + + foreach (var target in targets) + { + 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 kind = isQuality ? "Quality" : isTime ? "Timestamp" : ""; + if (!ledger.TryGet(reference, fc, out var observed, + requiredEvidence: kind) || + observed.IsStale || observed.Association != ledger.Association) + { + missing++; + continue; + } + fresh++; + if (isQuality) + { + qObserved++; + if (observed.QualityBits is not null) qDecoded++; + } + if (isTime) + { + tObserved++; + 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))) + { + 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 cfEvidence = new IedBrowserCfEvidencePresentation(ledger, model, null); + var proved = cfOwners.Count(x => + cfEvidence.TryFormatUnitsParent(x, "CF", out _, out _, out var authority) && + authority == "Verified IEC CF configuration"); + var failedSamples = failures?.Count(x => + x.Kind is IedBrowserReadAbsenceKind.Failed or + IedBrowserReadAbsenceKind.Interrupted) ?? 0; + return new IedBrowserAcceptanceMetrics(targets.Length, fresh, + missing, failedSamples, cfOwners.Length, proved, + sgObserved, sgDecoded, qObserved, qDecoded, tObserved, tDecoded); + } + + private static string Key(string reference, string fc) + => IedBrowserLiveValues.CanonicalReference(reference) + "|" + + (fc ?? "").Trim().ToUpperInvariant(); +} From e50b3d5e6b9b8c3b3927d4726acab1c67e192e09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:58:30 +0700 Subject: [PATCH 076/184] test(P0): exact SSOT acceptance metrics with q/t raw absence, CF pairing, unknown SG enum and reconnect --- .../P0LiveAcceptanceCoverageTests.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/ARSAS.Tests/P0LiveAcceptanceCoverageTests.cs diff --git a/tests/ARSAS.Tests/P0LiveAcceptanceCoverageTests.cs b/tests/ARSAS.Tests/P0LiveAcceptanceCoverageTests.cs new file mode 100644 index 000000000..6679811f6 --- /dev/null +++ b/tests/ARSAS.Tests/P0LiveAcceptanceCoverageTests.cs @@ -0,0 +1,126 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; +using ArIED61850Tester.Protocol.Mms; + +namespace ARSAS.Tests; + +public sealed class P0LiveAcceptanceCoverageTests +{ + private static LiveIedModelDiscoveryDocument Model() + { + LiveIedDataAttributeModel Attr(string owner, string leaf, string fc, + string type) => new() + { + ObjectReference = owner, + AttributePath = owner[(owner.IndexOf('.') + 1)..] + "." + leaf, + MmsReference = owner + "." + leaf, + FunctionalConstraint = fc, + SclBType = type, + MmsType = type + }; + var phase = "LD/MMXU1.PhV"; + var setting = "LD/PIOC1.InMagTyp"; + return new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", LnClass = "MMXU", + DataObjects = [ + new LiveIedDataObjectModel + { + Reference = phase, Name = "PhV", + Attributes = [ + Attr(phase, "mag.f", "MX", "FLOAT32"), + Attr(phase, "q", "MX", "Quality"), + Attr(phase, "t", "MX", "Timestamp"), + Attr(phase, "units.SIUnit", "CF", "Enum"), + Attr(phase, "units.multiplier", "CF", "Enum") + ] + },new LiveIedDataObjectModel + { + Reference = setting, Name = "InMagTyp", + Attributes = [Attr(setting, "setVal", "SG", "Enum")] + } + ] + }] + }] + }; + } + + [Fact] + public void Metrics01_ExactAssociationAndFcCountOnlyObservedNoGuessedDecoding() + { + var model = Model(); + var ledger = new IedBrowserLiveValues(); + var token = ledger.BeginAssociation(); + ledger.PublishForAssociation(token, "LD/MMXU1.PhV.mag.f", "MX", + "12.75", "", "", "MMS Read"); + ledger.PublishForAssociation(token, "LD/MMXU1.PhV.q", "MX", + "good", "", "", "Report quality"); + ledger.PublishForAssociation(token, "LD/MMXU1.PhV.t", "MX", + "2026-10-18T00:00:00Z", "", "", "Report timestamp"); + ledger.PublishForAssociation(token, "LD/MMXU1.PhV.units.SIUnit", "CF", + "29", "", "", "MMS Read"); + ledger.PublishForAssociation(token, "LD/MMXU1.PhV.units.multiplier", "CF", + "3", "", "", "MMS Read"); + ledger.PublishForAssociation(token, "LD/PIOC1.InMagTyp.setVal", "SG", + "0", "", "", "MMS Read"); + + var before = IedBrowserAcceptanceMetrics.Capture(model, ledger); + Assert.Equal(6, before.ReadableAttributes); + Assert.Equal(6, before.FreshObserved); + Assert.Equal(0, before.MissingOrDeferred); + Assert.Equal(1, before.CfPairOwners); + Assert.Equal(1, before.CfPairsProven); + Assert.Equal(1, before.SgEnumObserved); + Assert.Equal(0, before.SgEnumDecoded); + Assert.Equal(1, before.QualityObserved); + Assert.Equal(0, before.QualityDecoded); + Assert.Equal(1, before.TimestampObserved); + Assert.Equal(0, before.TimestampDecoded); + Assert.Null(before.ConsumerMismatchCount); + + Assert.True(MmsQualityDecoder.TryDecode( + MmsDataValue.BitString(3, [0, 0]).RawValue.ToArray(), out var q)); + Assert.True(ledger.PublishForAssociation(token, "LD/MMXU1.PhV.q", "MX", + q.Summary, "", "", "Report quality · raw MMS", + qualityEvidence: q)); + var after = IedBrowserAcceptanceMetrics.Capture(model, ledger); + Assert.Equal(1, after.QualityDecoded); + Assert.Equal(0, after.TimestampDecoded); + Assert.Null(after.ConsumerMismatchCount); + + ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(token, "LD/MMXU1.PhV.mag.f", + "MX", "99", "", "", "MMS Read")); + var reset = IedBrowserAcceptanceMetrics.Capture(model, ledger); + Assert.Equal(0, reset.FreshObserved); + Assert.Equal(6, reset.MissingOrDeferred); + Assert.Equal(0, reset.CfPairsProven); + } + + [Fact] + public void Metrics02_NoSiblingCfPairingAndFcMismatchCanInflateProof() + { + var model = Model(); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + ledger.PublishForAssociation(association, "LD/MMXU1.PhV.units.SIUnit", + "CF", "29", "", "", "MMS Read"); + ledger.PublishForAssociation(association, "LD/MMXU1.Other.units.multiplier", + "CF", "3", "", "", "MMS Read"); + ledger.PublishForAssociation(association, "LD/MMXU1.PhV.units.multiplier", + "SG", "3", "", "", "MMS Read"); + var m = IedBrowserAcceptanceMetrics.Capture(model, ledger, + failures: [new IedBrowserReadFailureSample("LD/MMXU1.PhV.mag.f", "MX", + "MMS timeout")]); + Assert.Equal(1, m.CfPairOwners); + Assert.Equal(0, m.CfPairsProven); + Assert.Equal(1, m.FailedReadSamples); + Assert.Equal(5, m.MissingOrDeferred); + } +} From d72c3a23d259bb4118a87fa9959e68b64f9191b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 09:58:46 +0700 Subject: [PATCH 077/184] feat(P0): expose bounded SSOT acceptance coverage in existing Browser subtitle tooltip --- MainWindow.IedBrowser.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 38ccb9260..2156b7b98 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -738,6 +738,20 @@ private void RefreshIedBrowserVisibleValues() var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; var cfEvidence = new IedBrowserCfEvidencePresentation(device.LiveValues, model, device.LiveCanonicalModel); + // Read-only association-scoped acceptance counters are surfaced in + // the EXISTING subtitle tooltip, without resizing any live panel or + // 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; + } + else + IedBrowserSubtitle.ToolTip = + "Offline/SCL declarations are not proof of fresh IEC MMS observations."; var engineeringUnits = cfEvidence.BuildVerifiedUnits(device.Signals, model); foreach (var row in _iedBrowserDetails.Rows) { From ed594aac2289a5d8e5ad2ba36e3044d8da31d064 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 10:02:22 +0700 Subject: [PATCH 078/184] fix(P0): fence SCL EnumType metadata flights by MMS association and clear on offline --- MainWindow.IedBrowser.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 2156b7b98..d5894c3a7 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1514,7 +1514,9 @@ private void QueueIedBrowserVerifiedSclEnums(Iec61850MonitorDevice device) string.IsNullOrWhiteSpace(sourceSha)) { if (!ReferenceEquals(_iedBrowserSclEnumDevice, device) || - !ReferenceEquals(_iedBrowserSclEnumLiveModel, model)) + !ReferenceEquals(_iedBrowserSclEnumLiveModel, model) || + _iedBrowserSclEnumEvidence is not null || + _iedBrowserSclEnumSourceSha.Length != 0) { _iedBrowserSclEnumLoadTicket++; _iedBrowserSclEnumEvidence = null; @@ -1537,7 +1539,9 @@ private void QueueIedBrowserVerifiedSclEnums(Iec61850MonitorDevice device) { sourceStamp = -1; } - var signature = sourcePath + "|" + sourceSha + "|" + sourceStamp; + var association = device.LiveValues.Association; + var signature = sourcePath + "|" + sourceSha + "|" + sourceStamp + + "|" + association; if (ReferenceEquals(_iedBrowserSclEnumDevice, device) && ReferenceEquals(_iedBrowserSclEnumLiveModel, model) && string.Equals(_iedBrowserSclEnumSourceSha, signature, @@ -1550,14 +1554,16 @@ private void QueueIedBrowserVerifiedSclEnums(Iec61850MonitorDevice device) _iedBrowserSclEnumAbsenceReason = "Verifying the matching SCL EnumType."; var ticket = ++_iedBrowserSclEnumLoadTicket; _ = LoadIedBrowserVerifiedSclEnumsAsync( - device, model, design, sourcePath, sourceSha, signature, ticket); + device, model, design, sourcePath, sourceSha, signature, + association, ticket); } private async Task LoadIedBrowserVerifiedSclEnumsAsync( Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument liveModel, AR.Iec61850.Scl.Workspace.SclIedWorkspace design, - string sourcePath, string sourceSha, string signature, int ticket) + string sourcePath, string sourceSha, string signature, + long association, int ticket) { try { @@ -1569,6 +1575,7 @@ private async Task LoadIedBrowserVerifiedSclEnumsAsync( if (ticket != _iedBrowserSclEnumLoadTicket || !ReferenceEquals(SelectedDevice, device) || !ReferenceEquals(device.LiveDiscoveryModel, liveModel) || + device.LiveValues.Association != association || !ReferenceEquals(device.SclWorkspace, design) || !string.Equals(_iedBrowserSclEnumSourceSha, signature, StringComparison.Ordinal) || !device.IsConnected) From 63d5858df5250e7dd010a1195beb2a74173845f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 10:03:22 +0700 Subject: [PATCH 079/184] fix(P0): reject contradictory live MMS boolean vs SCL EnumType and require GVA exact type source --- Models/IedBrowserSclEnumEvidence.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Models/IedBrowserSclEnumEvidence.cs b/Models/IedBrowserSclEnumEvidence.cs index 080c49149..b8dbac75e 100644 --- a/Models/IedBrowserSclEnumEvidence.cs +++ b/Models/IedBrowserSclEnumEvidence.cs @@ -85,9 +85,16 @@ public static IedBrowserSclEnumEvidence Build( .Where(g => g.Count() == 1 && g.Single().TypeDiscoveryStatus.Equals("Exact", StringComparison.OrdinalIgnoreCase) && + g.Single().TypeSource.StartsWith( + "GetVariableAccessAttributes", StringComparison.Ordinal) && + // When MMS wire typing explicitly disagrees with SCL enum, + // fail CLOSED. SclBType is only a fallback when the trusted + // exact GVA result leaves its MMS primitive unspecified. (g.Single().MmsType.Equals("enumerated", StringComparison.OrdinalIgnoreCase) || g.Single().MmsType.Equals("enum", StringComparison.OrdinalIgnoreCase) || - g.Single().SclBType.Equals("Enum", StringComparison.OrdinalIgnoreCase))) + string.IsNullOrWhiteSpace(g.Single().MmsType) && + g.Single().SclBType.Equals("Enum", + StringComparison.OrdinalIgnoreCase))) .ToDictionary(g => g.Key, g => g.Single(), StringComparer.Ordinal); foreach (var attr in declarations) From 80531920a9aed9c69ba0f0eb607905e4d95ecd11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:17:00 +0700 Subject: [PATCH 080/184] fix(P0): remove intrusive Browser hover provenance and persist active Explorer row highlight --- MainWindow.xaml | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index afce98148..79623fd97 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -1796,11 +1796,11 @@ - - @@ -1834,7 +1834,7 @@ - @@ -1845,7 +1845,7 @@ observation; engineering schema alone cannot color an unevaluated signal as measured/healthy. --> @@ -1859,7 +1859,7 @@ - @@ -1874,7 +1874,7 @@ - + @@ -1907,7 +1907,7 @@ VerticalAlignment="Center"/> - + + + + + + + + + + + + + + @@ -2074,7 +2099,7 @@ FontSize="9" FontWeight="SemiBold" TextAlignment="Center" VerticalAlignment="Center"/> - Date: Sun, 18 Oct 2026 11:18:04 +0700 Subject: [PATCH 081/184] test(P0): BCUGE XCBR observed actVal/ST and setMag/SP parent parity with exact CF ownership and reconnect --- .../P0XcbrParentLeafFieldParityTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/ARSAS.Tests/P0XcbrParentLeafFieldParityTests.cs diff --git a/tests/ARSAS.Tests/P0XcbrParentLeafFieldParityTests.cs b/tests/ARSAS.Tests/P0XcbrParentLeafFieldParityTests.cs new file mode 100644 index 000000000..3f85ce069 --- /dev/null +++ b/tests/ARSAS.Tests/P0XcbrParentLeafFieldParityTests.cs @@ -0,0 +1,128 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0XcbrParentLeafFieldParityTests +{ + private const string Prefix = "BCUGEF650/XCBR1."; + + private static LiveIedDataObjectModel Object(string name, params (string Leaf, string Fc, string Type)[] leaves) + => new() + { + Reference = Prefix + name, Name = name, InferredCdc = "INS", + Attributes = leaves.Select(x => new LiveIedDataAttributeModel + { + ObjectReference = Prefix + name, + AttributePath = name + "." + x.Leaf, + MmsReference = Prefix + name + "." + x.Leaf, + FunctionalConstraint = x.Fc, + SclBType = x.Type + }).ToArray() + }; + + [Fact] + public void Xcbr01_ActValStObservedHasOneUnambiguousParentReference() + { + var obj = Object("SumSwARs", ("actVal", "ST", "INT32U"), + ("q", "ST", "Quality"), ("t", "ST", "Timestamp"), + ("units.SIUnit", "CF", "Enum"), + ("units.multiplier", "CF", "Enum")); + Assert.True(IedBrowserSchemaIndex.TryGetUniqueProcessLeaf(obj, + out var reference, out var fc)); + Assert.Equal(Prefix + "SumSwARs.actVal", reference); + Assert.Equal("ST", fc); + var row = IedBrowserStructuredDetails.DataObject(obj, "ST"); + Assert.Equal(reference, row.ValueReference); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(association, reference, fc, + "12", "", "", "MMS Read")); + Assert.True(ledger.TryGet(row.ValueReference, row.FunctionalConstraint, + out var observed)); + Assert.Equal("12", observed.Value); + Assert.False(ledger.TryGet(row.ValueReference, "SP", out _)); + } + + [Fact] + public void Xcbr02_SetMagSpProvidesObservedParentOnlyWhenNoStOrMxScalar() + { + var obj = Object("SumSwATmms", ("setMag", "SP", "FLOAT32"), + ("units.SIUnit", "CF", "Enum"), + ("units.multiplier", "CF", "Enum"), + ("minVal", "CF", "FLOAT32")); + Assert.True(IedBrowserSchemaIndex.TryGetUniqueProcessLeaf(obj, + out var reference, out var fc)); + Assert.Equal(Prefix + "SumSwATmms.setMag", reference); + Assert.Equal("SP", fc); + var row = IedBrowserStructuredDetails.DataObject(obj, null); + Assert.Equal(reference, row.ValueReference); + Assert.Equal("SP", row.FunctionalConstraint); + var ledger = new IedBrowserLiveValues(); + var a = ledger.BeginAssociation(); + ledger.PublishForAssociation(a, reference, fc, "0", "", "", "MMS Read"); + Assert.True(ledger.TryGet(row.ValueReference, row.FunctionalConstraint, out var observed)); + Assert.Equal("0", observed.Value); + } + + [Fact] + public void Xcbr03_AmbiguousScalarOrWrongFcIsNeverChildFirstFallback() + { + var ambiguous = Object("SumSwARs", + ("stVal", "ST", "INT32U"), ("actVal", "ST", "INT32U")); + Assert.False(IedBrowserSchemaIndex.TryGetUniqueProcessLeaf(ambiguous, out _, out _)); + var differentFc = Object("ThAlSwA", + ("setMag", "SP", "FLOAT32"), ("actVal", "ST", "INT32U")); + Assert.True(IedBrowserSchemaIndex.TryGetUniqueProcessLeaf(differentFc, + out var exact, out var fc)); + Assert.Equal(Prefix + "ThAlSwA.actVal", exact); + Assert.Equal("ST", fc); + var noProcess = Object("CBOpCap", ("q", "ST", "Quality"), + ("units.SIUnit", "CF", "Enum")); + Assert.False(IedBrowserSchemaIndex.TryGetUniqueProcessLeaf(noProcess, out _, out _)); + } + + [Fact] + public void Xcbr04_ActValAndSetMagUnitsNeedFreshExactCfPair() + { + var obj1 = Object("SumSwARs", ("actVal", "ST", "INT32U"), + ("units.SIUnit", "CF", "Enum"), ("units.multiplier", "CF", "Enum")); + var obj2 = Object("SumSwATmms", ("setMag", "SP", "FLOAT32"), + ("units.SIUnit", "CF", "Enum"), ("units.multiplier", "CF", "Enum")); + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "BCUGEF650", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "XCBR1", LnClass = "XCBR", DataObjects = [obj1, obj2] + }] + }] + }; + var values = new IedBrowserLiveValues(); + var association = values.BeginAssociation(); + values.PublishForAssociation(association, Prefix + "SumSwARs.units.SIUnit", + "CF", "70", "", "", "MMS Read"); + values.PublishForAssociation(association, Prefix + "SumSwATmms.units.SIUnit", + "CF", "4", "", "", "MMS Read"); + var cf = new IedBrowserCfEvidencePresentation(values, model, null); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + Prefix + "SumSwARs.actVal", "ST", out _, out _)); + values.PublishForAssociation(association, Prefix + "SumSwARs.units.multiplier", + "CF", "3", "", "", "MMS Read"); + values.PublishForAssociation(association, Prefix + "SumSwATmms.units.multiplier", + "CF", "0", "", "", "MMS Read"); + Assert.True(cf.TryFormatVerifiedNumericStatusUnit( + Prefix + "SumSwARs.actVal", "ST", out var a, out _)); + Assert.Equal("kA²t", a); + Assert.True(cf.TryFormatVerifiedNumericStatusUnit( + Prefix + "SumSwATmms.setMag", "SP", out var s, out _)); + Assert.Equal("s", s); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + Prefix + "SumSwATmms.setMag", "SG", out _, out _)); + values.BeginAssociation(); + Assert.False(cf.TryFormatVerifiedNumericStatusUnit( + Prefix + "SumSwATmms.setMag", "SP", out _, out _)); + } +} From 14d98a32b0f9cb54565ef46562495fc82bc08f22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:18:21 +0700 Subject: [PATCH 082/184] fix(P0): exact XCBR actVal ST and setMag SP DO scalar selection without sibling or FC inference --- Models/IedBrowserSchemaIndex.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Models/IedBrowserSchemaIndex.cs b/Models/IedBrowserSchemaIndex.cs index 781d21c58..9419a733e 100644 --- a/Models/IedBrowserSchemaIndex.cs +++ b/Models/IedBrowserSchemaIndex.cs @@ -27,7 +27,9 @@ public sealed class IedBrowserSchemaIndex new(StringComparer.Ordinal); private static readonly HashSet ProcessLeaves = - new(["stVal", "posVal", "valWTr", "general", "mag.f", "instMag.f", "cVal.mag.f", "instCVal.mag.f"], + new(["stVal", "actVal", "posVal", "valWTr", "general", + "mag.f", "instMag.f", "cVal.mag.f", "instCVal.mag.f", + "setMag", "setMag.f"], StringComparer.Ordinal); public LiveIedModelDiscoveryDocument SourceModel { get; } @@ -143,7 +145,22 @@ public static bool TryGetUniqueProcessLeaf( .Distinct() .Take(3) .ToArray(); - if (candidates.Length == 0 || candidates.Select(x => x.Fc).Distinct().Count() != 1) + // IEC APC/ASG-like setting magnitude is a scalar value *only* + // when this DO declares no ST/MX process scalar. Read-only SP must + // never override a live ST counter nor borrow another DO's value. + if (candidates.Length == 0) + { + var setting = obj.Attributes + .Where(a => NormalizeFc(a.FunctionalConstraint) == "SP") + .Select(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference)) + .Where(p => p == owner + ".setMag" || p == owner + ".setMag.f") + .Distinct(StringComparer.Ordinal).Take(2).ToArray(); + if (setting.Length != 1) return false; + leaf = setting[0]; + fc = "SP"; + return true; + } + if (candidates.Select(x => x.Fc).Distinct().Count() != 1) return false; if (!TrySelectDisplayLeaf(owner, candidates.Select(x => x.Reference), out leaf)) From 55ba70c88514394e0e9d32761c96474c8d6ca306 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:18:51 +0700 Subject: [PATCH 083/184] fix(P0): require fresh exact owner CF for typed XCBR actVal and SP setMag unit, preserve enum safety --- Models/IedBrowserCfEvidencePresentation.cs | 59 +++++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/Models/IedBrowserCfEvidencePresentation.cs b/Models/IedBrowserCfEvidencePresentation.cs index ab68af794..5f6212272 100644 --- a/Models/IedBrowserCfEvidencePresentation.cs +++ b/Models/IedBrowserCfEvidencePresentation.cs @@ -73,17 +73,28 @@ private static SchemaIndex BuildSchema(LiveIedModelDiscoveryDocument model) { var reference = IedBrowserLiveValues.CanonicalReference(a.MmsReference); if (reference.Length == 0) continue; - if (string.Equals(a.FunctionalConstraint, "ST", - StringComparison.OrdinalIgnoreCase) && - reference.EndsWith(".stVal", StringComparison.Ordinal)) + var fc = (a.FunctionalConstraint ?? "").Trim().ToUpperInvariant(); + // Only a typed exact scalar DA can inherit the units of its + // OWN IEC DO. XCBR accumulations use actVal[ST], while some + // threshold/time settings expose setMag[SP] rather than stVal. + var suffix = fc == "ST" && + reference.EndsWith(".stVal", StringComparison.Ordinal) + ? ".stVal" : fc == "ST" && + reference.EndsWith(".actVal", StringComparison.Ordinal) + ? ".actVal" : fc == "SP" && + reference.EndsWith(".setMag.f", StringComparison.Ordinal) + ? ".setMag.f" : fc == "SP" && + reference.EndsWith(".setMag", StringComparison.Ordinal) + ? ".setMag" : ""; + if (suffix.Length > 0) { - var owner = reference[..^".stVal".Length]; + var owner = reference[..^suffix.Length]; if (exactOwners.Contains(owner) && string.Equals(IedBrowserLiveValues.CanonicalReference(a.ObjectReference), owner, StringComparison.Ordinal) && - IsTypedIntegerStatus(a)) + IsTypedNumericScalar(a, fc)) { - var key = Key(reference, "ST"); + var key = Key(reference, fc); numericStatusCounts[key] = numericStatusCounts.GetValueOrDefault(key) + 1; } @@ -203,6 +214,19 @@ private static bool IsTypedIntegerStatus(LiveIedDataAttributeModel declaration) mms is not ("bit-string" or "boolean" or "enumerated"); } + private static bool IsTypedNumericScalar( + LiveIedDataAttributeModel attribute, string fc) + { + if (fc == "ST") return IsTypedIntegerStatus(attribute); + if (fc != "SP") return false; + var btype = (attribute.SclBType ?? "").Trim().ToUpperInvariant(); + var mms = (attribute.MmsType ?? "").Trim().ToLowerInvariant(); + return btype is "FLOAT32" or "FLOAT64" or "INT8" or "INT16" or + "INT32" or "INT64" or "INT8U" or "INT16U" or "INT32U" or + "INT64U" || (btype.Length == 0 && + mms is "floating-point" or "integer" or "unsigned"); + } + private bool TryVerifyUnitPair(string owner, out string unit, out string provenance, out bool fresh) { @@ -244,15 +268,22 @@ public bool TryFormatVerifiedNumericStatusUnit( { unit = ""; provenance = ""; - if (_model is null || - !string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase)) - return false; + if (_model is null) return false; + var constraint = (fc ?? "").Trim().ToUpperInvariant(); var path = IedBrowserLiveValues.CanonicalReference(reference); - if (!path.EndsWith(".stVal", StringComparison.Ordinal)) return false; - var owner = path[..^".stVal".Length]; - if (!_numericStatusLeaves.Contains(Key(path, "ST"))) return false; - return TryVerifyUnitPair(owner + ".units", out unit, - out provenance, out var fresh) && fresh; + var suffix = constraint == "ST" && + path.EndsWith(".stVal", StringComparison.Ordinal) + ? ".stVal" : constraint == "ST" && + path.EndsWith(".actVal", StringComparison.Ordinal) + ? ".actVal" : constraint == "SP" && + path.EndsWith(".setMag.f", StringComparison.Ordinal) + ? ".setMag.f" : constraint == "SP" && + path.EndsWith(".setMag", StringComparison.Ordinal) + ? ".setMag" : ""; + if (suffix.Length == 0 || + !_numericStatusLeaves.Contains(Key(path, constraint))) return false; + return TryVerifyUnitPair(path[..^suffix.Length] + ".units", + out unit, out provenance, out var fresh) && fresh; } /// Exact standard enum registry, never infer units from DO names. From 64d5be3fdfc1b09c84e0b0053c8cdb89e412e3ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:19:07 +0700 Subject: [PATCH 084/184] fix(P0): one proof-only engineering unit path for XCBR numeric actVal and setMag parent/leaf --- .../IedBrowserEngineeringValuePresentation.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Models/IedBrowserEngineeringValuePresentation.cs b/Models/IedBrowserEngineeringValuePresentation.cs index cf83ff687..0b648c600 100644 --- a/Models/IedBrowserEngineeringValuePresentation.cs +++ b/Models/IedBrowserEngineeringValuePresentation.cs @@ -154,12 +154,21 @@ public static string ResolveObservedUnit( string observedReference, string engineeringReference, string fc, bool isEngineeringValue) { - var unit = isEngineeringValue ? ResolveUnit(units, engineeringReference, fc) : ""; var observed = IedBrowserLiveValues.CanonicalReference(observedReference); - if (unit.Length == 0 && string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) && - observed.EndsWith(".stVal", StringComparison.Ordinal) && - cfEvidence.TryFormatVerifiedNumericStatusUnit(observed, fc, out var numeric, out _)) - unit = numeric; + var normalizedFc = (fc ?? "").Trim().ToUpperInvariant(); + // IEC XCBR typed numeric status/setting: do NOT promote an + // unverified SignalDefinition unit. Only live paired CF of the exact + // owning DO may qualify an integer actVal or SP setMag. + var numericCandidate = normalizedFc == "ST" && + (observed.EndsWith(".stVal", StringComparison.Ordinal) || + observed.EndsWith(".actVal", StringComparison.Ordinal)) || + normalizedFc == "SP" && + (observed.EndsWith(".setMag", StringComparison.Ordinal) || + observed.EndsWith(".setMag.f", StringComparison.Ordinal)); + if (numericCandidate) + return cfEvidence.TryFormatVerifiedNumericStatusUnit( + observed, normalizedFc, out var proven, out _) ? proven : ""; + var unit = isEngineeringValue ? ResolveUnit(units, engineeringReference, fc) : ""; if (unit.Length == 0 && string.Equals(fc, "MX", StringComparison.OrdinalIgnoreCase) && observed.EndsWith(".ang.f", StringComparison.Ordinal)) unit = ResolveVectorAngleUnit(units, observed, fc); From 588d51a5bdd976a6b5e9d681fd2152cb797e25dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:20:13 +0700 Subject: [PATCH 085/184] test(P0): active Explorer selection visible without focus; remove intrusive data hover provenance --- .../P0BrowserActiveNavigationUxTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/ARSAS.Tests/P0BrowserActiveNavigationUxTests.cs diff --git a/tests/ARSAS.Tests/P0BrowserActiveNavigationUxTests.cs b/tests/ARSAS.Tests/P0BrowserActiveNavigationUxTests.cs new file mode 100644 index 000000000..81f0ae3b3 --- /dev/null +++ b/tests/ARSAS.Tests/P0BrowserActiveNavigationUxTests.cs @@ -0,0 +1,44 @@ +namespace ARSAS.Tests; + +public sealed class P0BrowserActiveNavigationUxTests +{ + private static string BrowserMarkup() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "MainWindow.xaml"))) + root = root.Parent; + Assert.NotNull(root); + return File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml")) + .Split("