From f396693620fab771d5c092a327eee97ee7297390 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 19:09:09 +0700 Subject: [PATCH 1/5] fix: enforce operational filter in live discovery selector --- ...rdWindow.LiveDiscoveryOperationalFilter.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs diff --git a/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs new file mode 100644 index 000000000..bfca93379 --- /dev/null +++ b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs @@ -0,0 +1,78 @@ +using System.Windows; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Owns the Signal Selection presentation contract after a live MMS discovery. +/// +/// The ARIEC/live SignalDefinition inventory intentionally remains complete enough for +/// diagnostics, comparison, report planning, persistence and engineering inspection. +/// Signal Selection is narrower: it shows operator points and mandatory static DataSet +/// members, never protocol/service leaves such as Mod/Beh/Health/NamPlt/origin/q/t. +/// +/// This filter is installed on the wizard after its constructor has installed search and +/// column filters. That lifecycle point is important because a default WPF collection view +/// can be shared with another DataGrid; a previously installed global filter can otherwise +/// be replaced by the wizard's own FilterSignal predicate. +/// +public partial class SignalSelectionWizardWindow +{ + private Predicate? _signalSelectionBaseFilter; + private Predicate? _signalSelectionOperationalFilter; + private bool _signalSelectionOperationalFilterInstalled; + + static SignalSelectionWizardWindow() + { + EventManager.RegisterClassHandler( + typeof(SignalSelectionWizardWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnSignalSelectionOperationalFilterLoaded)); + } + + private static void OnSignalSelectionOperationalFilterLoaded(object sender, RoutedEventArgs args) + { + if (sender is SignalSelectionWizardWindow window) + window.InstallSignalSelectionOperationalFilter(); + } + + private void InstallSignalSelectionOperationalFilter() + { + if (_signalSelectionOperationalFilterInstalled) + return; + + _signalSelectionBaseFilter = SignalsView.Filter; + _signalSelectionOperationalFilter = item => + { + if (_signalSelectionBaseFilter is not null && !_signalSelectionBaseFilter(item)) + return false; + + return item is SignalDefinition signal && + SasOperationalUiPolicy.IsPresentationVisible(signal); + }; + + SignalsView.Filter = _signalSelectionOperationalFilter; + _signalSelectionOperationalFilterInstalled = true; + Closed -= SignalSelectionOperationalFilter_Closed; + Closed += SignalSelectionOperationalFilter_Closed; + SignalsView.Refresh(); + RefreshViewState(); + } + + private void SignalSelectionOperationalFilter_Closed(object? sender, EventArgs args) + { + Closed -= SignalSelectionOperationalFilter_Closed; + + // Restore only our own wrapper. If another owner intentionally changed the shared + // collection view while this window was open, do not overwrite that newer filter. + if (_signalSelectionOperationalFilter is not null && + Equals(SignalsView.Filter, _signalSelectionOperationalFilter)) + { + SignalsView.Filter = _signalSelectionBaseFilter; + } + + _signalSelectionOperationalFilterInstalled = false; + _signalSelectionOperationalFilter = null; + _signalSelectionBaseFilter = null; + } +} From 60a013571096a696a86795c665db71720d079e47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 19:09:30 +0700 Subject: [PATCH 2/5] test: reject live discovery protocol noise from signal selection --- ...verySignalSelectionNoiseRegressionTests.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs diff --git a/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs new file mode 100644 index 000000000..3d903df31 --- /dev/null +++ b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs @@ -0,0 +1,137 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class LiveDiscoverySignalSelectionNoiseRegressionTests +{ + [Theory] + [InlineData("AA1C1F13R4ADD/GAPC1.Mod.stVal", "GAPC1", "ST", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4ADD/GAPC1.Beh.stVal", "GAPC1", "ST", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4ADD/GAPC1.Health.stVal", "GAPC1", "ST", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4ADD/GAPC1.NamPlt.d", "GAPC1", "DC", "VisString255", "Metadata", false)] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subVal", "GGIO1", "SV", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subQ", "GGIO1", "SV", "Quality", "Quality", false)] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.blkEna", "GGIO1", "BL", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.d.stVal", "GGIO1", "ST", "Boolean", "Status", false)] + [InlineData("AA1C1F13R4Application/LLN0.q.stVal", "LLN0", "ST", "Boolean", "Status", false)] + public void EngineeringAndProtocolLeaves_AreNotOperationalValues( + string reference, + string logicalNode, + string fc, + string dataType, + string category, + bool expected) + { + var signal = NewSignal(reference, logicalNode, fc, dataType, category); + + Assert.Equal(expected, SasOperationalSignalPolicy.IsVisible(signal)); + } + + [Theory] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.origin.orCat")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.origin.orIdent")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.ctlVal")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.ctlNum")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.Check")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.ctlModel")] + public void ControlServiceLeaves_AreNotOperationalControls(string reference) + { + var signal = NewSignal(reference, "GGIO1", "CO", "Struct", "Control"); + signal.IsControlSignal = true; + signal.ControlCdc = "SPC"; + + Assert.False(SasOperationalSignalPolicy.IsVisible(signal)); + } + + [Theory] + [InlineData("IEDLD/XCBR1.Pos.stVal", "XCBR1", "ST", "Enum", "Position")] + [InlineData("IEDLD/MMXU1.A.phsA.cVal.mag.f", "MMXU1", "MX", "Float32", "Measurement")] + [InlineData("IEDLD/PTRC1.Tr.general", "PTRC1", "ST", "Boolean", "Protection")] + [InlineData("IEDLD/GGIO1.Ind15.stVal", "GGIO1", "ST", "Boolean", "Status")] + public void RealOperatorPoints_RemainVisible( + string reference, + string logicalNode, + string fc, + string dataType, + string category) + { + var signal = NewSignal(reference, logicalNode, fc, dataType, category); + + Assert.True(SasOperationalSignalPolicy.IsVisible(signal)); + } + + [Fact] + public void RealPositionControl_RemainsVisible() + { + var signal = NewSignal("IEDLD/CSWI1.Pos", "CSWI1", "CO", "Struct", "Control"); + signal.IsControlSignal = true; + signal.ControlCdc = "DPC"; + + Assert.True(SasOperationalSignalPolicy.IsVisible(signal)); + } + + [Fact] + public void StaticDataSetObjectLevelMember_RemainsVisibleEvenWithoutRuntimeLeaf() + { + var signal = NewSignal( + "AA1C1F13R4ADD/GGIO6.CBOpnd", + "GGIO6", + "ST", + "Boolean", + "DataSet"); + signal.DataSetReference = "AA1C1F13R4Application/LLN0$Digital"; + signal.DisplayReference = signal.ObjectReference; + + Assert.False(SasOperationalSignalPolicy.IsVisible(signal)); + Assert.True(SasOperationalUiPolicy.IsPresentationVisible(signal)); + Assert.DoesNotContain(".stVal", signal.DisplayReference, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SignalSelectionWizard_InstallsOperationalFilterAfterConstructorFilters() + { + var source = File.ReadAllText(FindRepoFile("SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs")); + + Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); + Assert.Contains("_signalSelectionBaseFilter = SignalsView.Filter", source, StringComparison.Ordinal); + Assert.Contains("SasOperationalUiPolicy.IsPresentationVisible(signal)", source, StringComparison.Ordinal); + Assert.Contains("SignalsView.Filter = _signalSelectionOperationalFilter", source, StringComparison.Ordinal); + Assert.Contains("SignalsView.Filter = _signalSelectionBaseFilter", source, StringComparison.Ordinal); + Assert.DoesNotContain("RemoveAt(", source, StringComparison.Ordinal); + Assert.DoesNotContain("Signals.Clear", source, StringComparison.Ordinal); + } + + private static SignalDefinition NewSignal( + string reference, + string logicalNode, + string fc, + string dataType, + string category) + => new() + { + Name = logicalNode, + ObjectReference = reference, + FunctionalConstraint = fc, + DataType = dataType, + Category = category, + Source = "ARIEC61850 live discovery", + ProbeStatus = "Readable", + Value = "0", + Quality = "Good" + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From d6902b9d2eaf61491f9693d4db3e01b59728f447 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 19:10:36 +0700 Subject: [PATCH 3/5] fix: classify live MMS protocol noise before selector projection --- .../LiveDiscoverySignalSelectionPolicy.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 Services/LiveDiscoverySignalSelectionPolicy.cs diff --git a/Services/LiveDiscoverySignalSelectionPolicy.cs b/Services/LiveDiscoverySignalSelectionPolicy.cs new file mode 100644 index 000000000..cf3e02b70 --- /dev/null +++ b/Services/LiveDiscoverySignalSelectionPolicy.cs @@ -0,0 +1,66 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Signal Selection policy for broad online MMS discovery. +/// +/// Online GetNameList/VAA discovery is intentionally exhaustive. It contains process +/// values together with quality/timestamp companions, nameplate/configuration attributes, +/// substitution state, control-service structure and other engineering leaves. Those +/// objects remain available in the typed live model, but they are not independent operator +/// points and must not leak into Signal Selection. +/// +/// Static DataSet membership is authoritative and therefore wins over this presentation +/// noise classifier: an object explicitly configured in a DataSet remains visible even if +/// its object-level FCD identity is not an exact runtime value leaf. +/// +public static class LiveDiscoverySignalSelectionPolicy +{ + private static readonly HashSet NoiseSegments = new(StringComparer.OrdinalIgnoreCase) + { + "mod", "beh", "health", "eehealth", "namplt", + "q", "t", "tm", "d", "du", + "origin", "orcat", "orident", + "ctlmodel", "ctlval", "ctlnum", "stseld", + "sbo", "sbow", "oper", "cancel", "check", "test", + "datans", "subena", "subval", "subq", "subid", "blkena", + "configrev", "vendor", "swrev", "lnns", "numpts", "olddata" + }; + + public static bool IsVisible(SignalDefinition? signal) + { + if (signal is null || string.IsNullOrWhiteSpace(signal.ObjectReference)) + return false; + + if (!string.IsNullOrWhiteSpace(signal.DataSetReference)) + return true; + + if (IsProtocolOrEngineeringNoise(signal.ObjectReference)) + return false; + + return SasOperationalSignalPolicy.IsVisible(signal); + } + + public static bool IsProtocolOrEngineeringNoise(string? reference) + { + if (string.IsNullOrWhiteSpace(reference)) + return true; + + var normalized = reference.Trim().Replace('$', '.'); + var slash = normalized.IndexOf('/'); + var path = slash >= 0 && slash < normalized.Length - 1 + ? normalized[(slash + 1)..] + : normalized; + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Segment 0 is the Logical Node. Only data/control path segments are classified. + for (var index = 1; index < segments.Length; index++) + { + if (NoiseSegments.Contains(segments[index])) + return true; + } + + return false; + } +} From a3fc7f81a1f7b692a0f73bdc6a4d76aded61782c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 19:10:56 +0700 Subject: [PATCH 4/5] fix: apply field-hardened live discovery selection policy --- SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs index bfca93379..d34e6e283 100644 --- a/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs +++ b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs @@ -1,5 +1,6 @@ using System.Windows; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -48,7 +49,7 @@ private void InstallSignalSelectionOperationalFilter() return false; return item is SignalDefinition signal && - SasOperationalUiPolicy.IsPresentationVisible(signal); + LiveDiscoverySignalSelectionPolicy.IsVisible(signal); }; SignalsView.Filter = _signalSelectionOperationalFilter; From 5ef2d01e6ebe48fd0eb6c8635670181ba6c77578 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 19:11:21 +0700 Subject: [PATCH 5/5] test: harden field-derived live discovery noise classification --- ...verySignalSelectionNoiseRegressionTests.cs | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs index 3d903df31..1ad716d4a 100644 --- a/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs +++ b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs @@ -6,26 +6,26 @@ namespace ARSAS.Tests; public sealed class LiveDiscoverySignalSelectionNoiseRegressionTests { [Theory] - [InlineData("AA1C1F13R4ADD/GAPC1.Mod.stVal", "GAPC1", "ST", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4ADD/GAPC1.Beh.stVal", "GAPC1", "ST", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4ADD/GAPC1.Health.stVal", "GAPC1", "ST", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4ADD/GAPC1.NamPlt.d", "GAPC1", "DC", "VisString255", "Metadata", false)] - [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subVal", "GGIO1", "SV", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subQ", "GGIO1", "SV", "Quality", "Quality", false)] - [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.blkEna", "GGIO1", "BL", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.d.stVal", "GGIO1", "ST", "Boolean", "Status", false)] - [InlineData("AA1C1F13R4Application/LLN0.q.stVal", "LLN0", "ST", "Boolean", "Status", false)] - public void EngineeringAndProtocolLeaves_AreNotOperationalValues( + [InlineData("AA1C1F13R4ADD/GAPC1.Mod.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.Beh.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.Health.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.NamPlt.d", "GAPC1", "DC", "VisString255", "Metadata")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subVal", "GGIO1", "SV", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subQ", "GGIO1", "SV", "Quality", "Quality")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.blkEna", "GGIO1", "BL", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.d.stVal", "GGIO1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4Application/LLN0.q.stVal", "LLN0", "ST", "Boolean", "Status")] + public void EngineeringAndProtocolLeaves_AreRejectedFromLiveSignalSelection( string reference, string logicalNode, string fc, string dataType, - string category, - bool expected) + string category) { var signal = NewSignal(reference, logicalNode, fc, dataType, category); - Assert.Equal(expected, SasOperationalSignalPolicy.IsVisible(signal)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.False(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); } [Theory] @@ -35,13 +35,14 @@ public void EngineeringAndProtocolLeaves_AreNotOperationalValues( [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.ctlNum")] [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.Check")] [InlineData("AA1C1F13R4ADD/GGIO1.Mod.ctlModel")] - public void ControlServiceLeaves_AreNotOperationalControls(string reference) + public void ControlServiceLeaves_AreRejectedFromLiveSignalSelection(string reference) { var signal = NewSignal(reference, "GGIO1", "CO", "Struct", "Control"); signal.IsControlSignal = true; signal.ControlCdc = "SPC"; - Assert.False(SasOperationalSignalPolicy.IsVisible(signal)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.False(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); } [Theory] @@ -58,7 +59,8 @@ public void RealOperatorPoints_RemainVisible( { var signal = NewSignal(reference, logicalNode, fc, dataType, category); - Assert.True(SasOperationalSignalPolicy.IsVisible(signal)); + Assert.False(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); } [Fact] @@ -68,7 +70,7 @@ public void RealPositionControl_RemainsVisible() signal.IsControlSignal = true; signal.ControlCdc = "DPC"; - Assert.True(SasOperationalSignalPolicy.IsVisible(signal)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); } [Fact] @@ -84,10 +86,24 @@ public void StaticDataSetObjectLevelMember_RemainsVisibleEvenWithoutRuntimeLeaf( signal.DisplayReference = signal.ObjectReference; Assert.False(SasOperationalSignalPolicy.IsVisible(signal)); - Assert.True(SasOperationalUiPolicy.IsPresentationVisible(signal)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); Assert.DoesNotContain(".stVal", signal.DisplayReference, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void StaticDataSetAuthority_WinsEvenIfConfiguredMemberLooksLikeEngineeringState() + { + var signal = NewSignal( + "IEDLD/LLN0.Beh", + "LLN0", + "ST", + "Enum", + "DataSet"); + signal.DataSetReference = "IEDLD/LLN0$Configured"; + + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + [Fact] public void SignalSelectionWizard_InstallsOperationalFilterAfterConstructorFilters() { @@ -95,7 +111,7 @@ public void SignalSelectionWizard_InstallsOperationalFilterAfterConstructorFilte Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); Assert.Contains("_signalSelectionBaseFilter = SignalsView.Filter", source, StringComparison.Ordinal); - Assert.Contains("SasOperationalUiPolicy.IsPresentationVisible(signal)", source, StringComparison.Ordinal); + Assert.Contains("LiveDiscoverySignalSelectionPolicy.IsVisible(signal)", source, StringComparison.Ordinal); Assert.Contains("SignalsView.Filter = _signalSelectionOperationalFilter", source, StringComparison.Ordinal); Assert.Contains("SignalsView.Filter = _signalSelectionBaseFilter", source, StringComparison.Ordinal); Assert.DoesNotContain("RemoveAt(", source, StringComparison.Ordinal);