From 1fa37580b5120732ac8818fa5a8b7dbd54236d88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 09:20:34 +0700 Subject: [PATCH 1/5] Fix strict partial-leaf FAT signal matching --- Services/IoTesting/IoTestReferenceMatcher.cs | 91 +++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/IoTestReferenceMatcher.cs b/Services/IoTesting/IoTestReferenceMatcher.cs index bee398fea..063190a01 100644 --- a/Services/IoTesting/IoTestReferenceMatcher.cs +++ b/Services/IoTesting/IoTestReferenceMatcher.cs @@ -5,8 +5,9 @@ namespace ArIED61850Tester.Services.IoTesting; /// /// Conservative IEC 61850 reference matcher used by FAT binding. It understands /// equivalent MMS/SCL spellings (IED-prefixed domains, the DIGSI Application display -/// wrapper, functional-constraint tokens inside MMS references, and verified Siemens -/// functional-group/LN display folders) but never uses fuzzy text similarity. +/// wrapper, functional-constraint tokens inside MMS references, verified Siemens +/// functional-group/LN display folders, and exact unique object-leaf recovery for +/// incomplete imported references) but never uses fuzzy text similarity. /// internal static class IoTestReferenceMatcher { @@ -24,6 +25,7 @@ internal static class IoTestReferenceMatcher internal const int ExactScore = 100; internal const int CanonicalScore = 90; internal const int ContainerScore = 70; + internal const int PartialObjectScore = 60; internal static int Score( string? importedReference, @@ -55,6 +57,20 @@ internal static int Score( } } + // Some customer FAT sheets contain a valid exact data-object name but have + // lost the LD/LN path, for example `.TCS1Fail`. Recover that case only by an + // exact IEC object-leaf boundary match. This deliberately scores below every + // fully attributable form; the caller still requires one unique best candidate, + // so duplicate leaves in different logical nodes remain ambiguous and blocked. + foreach (var expected in importedForms) + { + foreach (var observed in observedForms) + { + if (IsSafePartialObjectMatch(expected, observed)) + return PartialObjectScore; + } + } + return 0; } @@ -208,4 +224,75 @@ private static bool IsSafeImplicitLeafMatch(string expected, string observed) var suffix = observed[(expected.Length + 1)..]; return SafeImplicitValueLeaves.Contains(suffix); } + + private static bool IsSafePartialObjectMatch(string expected, string observed) + { + var expectedObject = ParsePartialImportedObject(expected); + if (expectedObject is null) + return false; + + var observedObject = ParseObservedObject(observed); + if (observedObject is null || + !expectedObject.ObjectName.Equals(observedObject.ObjectName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (expectedObject.ValueSuffix.Length > 0) + { + return expectedObject.ValueSuffix.Equals( + observedObject.ValueSuffix, + StringComparison.OrdinalIgnoreCase); + } + + return observedObject.ValueSuffix.Length == 0 || + SafeImplicitValueLeaves.Contains(observedObject.ValueSuffix); + } + + private static ObjectLeaf? ParsePartialImportedObject(string value) + { + var normalized = (value ?? string.Empty).Trim().TrimStart('.').TrimEnd('.'); + if (normalized.Length == 0 || normalized.Contains('/')) + return null; + + var stripped = StripSafeValueSuffix(normalized); + if (stripped.Base.Length == 0 || stripped.Base.Contains('.') || !IsIecIdentifier(stripped.Base)) + return null; + + return new ObjectLeaf(stripped.Base, stripped.Suffix); + } + + private static ObjectLeaf? ParseObservedObject(string value) + { + var normalized = (value ?? string.Empty).Trim().Trim('.'); + if (normalized.Length == 0) + return null; + + var stripped = StripSafeValueSuffix(normalized); + var separator = Math.Max(stripped.Base.LastIndexOf('/'), stripped.Base.LastIndexOf('.')); + var objectName = separator >= 0 ? stripped.Base[(separator + 1)..] : stripped.Base; + if (!IsIecIdentifier(objectName)) + return null; + + return new ObjectLeaf(objectName, stripped.Suffix); + } + + private static (string Base, string Suffix) StripSafeValueSuffix(string value) + { + foreach (var leaf in SafeImplicitValueLeaves.OrderByDescending(item => item.Length)) + { + var suffix = "." + leaf; + if (!value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + continue; + + return (value[..^suffix.Length], leaf.ToLowerInvariant()); + } + + return (value, string.Empty); + } + + private static bool IsIecIdentifier(string value) + => Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant); + + private sealed record ObjectLeaf(string ObjectName, string ValueSuffix); } From ee42e3456c6d0fa80bd09278d45b1a23ebae3159 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 09:21:02 +0700 Subject: [PATCH 2/5] Cover TCS partial-reference regression --- .../IoTestSmartReferenceMatcherTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs index 20cd702e4..2a77662a9 100644 --- a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs +++ b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs @@ -74,6 +74,55 @@ public void EqualSmartCandidates_RemainAmbiguousInsteadOfBeingGuessed() Assert.Empty(result.MissingPoints); } + [Fact] + public void PartialTcsLeafReferences_ResolveUniquelyWithoutFuzzyGuessing() + { + var ied = FieldIed( + Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-"), + Point("UCC-IEC-TCS2", ".TCS2Fail", "ST", "-")); + var device = FieldDevice( + Signal("Trip coil monitoring 1", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Fail$stVal", "ST"), + Signal("Trip coil monitoring 2", "AA1C1F13R4Application/ADDGGIO2$ST$TCS2Fail$stVal", "ST")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Equal(2, result.Matches.Count); + Assert.Empty(result.MissingPoints); + Assert.Empty(result.AmbiguousPoints); + Assert.All(result.Matches, match => Assert.True(match.UsedNormalizedIedPrefix)); + } + + [Fact] + public void PartialLeafReference_RemainsAmbiguousWhenObjectOccursInTwoLogicalNodes() + { + var ied = FieldIed(Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-")); + var device = FieldDevice( + Signal("TCS from ADD", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Fail$stVal", "ST"), + Signal("TCS from alternate LN", "AA1C1F13R4Application/ALTGGIO3$ST$TCS1Fail$stVal", "ST")); + + var result = _service.Resolve(ied, device); + + Assert.False(result.Succeeded); + Assert.Empty(result.MissingPoints); + Assert.Single(result.AmbiguousPoints); + } + + [Fact] + public void PartialLeafReference_DoesNotUseNearTextSimilarity() + { + var ied = FieldIed(Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-")); + var device = FieldDevice( + Signal("Different numbered failure", "AA1C1F13R4Application/ADDGGIO2$ST$TCS11Fail$stVal", "ST"), + Signal("Healthy state", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Healthy$stVal", "ST")); + + var result = _service.Resolve(ied, device); + + Assert.False(result.Succeeded); + Assert.Single(result.MissingPoints); + Assert.Empty(result.AmbiguousPoints); + } + private static IoTestIedPlan Ied(params IoTestPointPlan[] points) => new() { IedName = "AA1C1F06R2", @@ -82,6 +131,19 @@ public void EqualSmartCandidates_RemainAmbiguousInsteadOfBeingGuessed() TestPoints = points.ToList() }; + private static IoTestIedPlan FieldIed(params IoTestPointPlan[] points) => new() + { + IedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + IedRole = "BCU - 6MD85", + TestPoints = points.Select(point => + { + point.IedName = "AA1C1F13R4"; + point.IpAddress = "192.168.81.17"; + return point; + }).ToList() + }; + private static IoTestPointPlan Point(string id, string reference, string fc, string logicalNode) => new() { TestPointId = id, @@ -110,6 +172,19 @@ private static Iec61850MonitorDevice Device(params SignalDefinition[] signals) return device; } + private static Iec61850MonitorDevice FieldDevice(params SignalDefinition[] signals) + { + var device = new Iec61850MonitorDevice + { + Name = "AA1C1F13R4", + SclIedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + Port = 102 + }; + device.Signals.AddRange(signals); + return device; + } + private static SignalDefinition Signal(string name, string reference, string fc) => new() { Name = name, From 807bfd053076dcbb339d6b6c889b54c604c18f0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 09:22:32 +0700 Subject: [PATCH 3/5] Fix TCS regression test fixtures --- .../IoTestSmartReferenceMatcherTests.cs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs index 2a77662a9..5f74472ce 100644 --- a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs +++ b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs @@ -78,8 +78,8 @@ public void EqualSmartCandidates_RemainAmbiguousInsteadOfBeingGuessed() public void PartialTcsLeafReferences_ResolveUniquelyWithoutFuzzyGuessing() { var ied = FieldIed( - Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-"), - Point("UCC-IEC-TCS2", ".TCS2Fail", "ST", "-")); + FieldPoint("UCC-IEC-TCS1", ".TCS1Fail"), + FieldPoint("UCC-IEC-TCS2", ".TCS2Fail")); var device = FieldDevice( Signal("Trip coil monitoring 1", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Fail$stVal", "ST"), Signal("Trip coil monitoring 2", "AA1C1F13R4Application/ADDGGIO2$ST$TCS2Fail$stVal", "ST")); @@ -96,7 +96,7 @@ public void PartialTcsLeafReferences_ResolveUniquelyWithoutFuzzyGuessing() [Fact] public void PartialLeafReference_RemainsAmbiguousWhenObjectOccursInTwoLogicalNodes() { - var ied = FieldIed(Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-")); + var ied = FieldIed(FieldPoint("UCC-IEC-TCS1", ".TCS1Fail")); var device = FieldDevice( Signal("TCS from ADD", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Fail$stVal", "ST"), Signal("TCS from alternate LN", "AA1C1F13R4Application/ALTGGIO3$ST$TCS1Fail$stVal", "ST")); @@ -111,7 +111,7 @@ public void PartialLeafReference_RemainsAmbiguousWhenObjectOccursInTwoLogicalNod [Fact] public void PartialLeafReference_DoesNotUseNearTextSimilarity() { - var ied = FieldIed(Point("UCC-IEC-TCS1", ".TCS1Fail", "ST", "-")); + var ied = FieldIed(FieldPoint("UCC-IEC-TCS1", ".TCS1Fail")); var device = FieldDevice( Signal("Different numbered failure", "AA1C1F13R4Application/ADDGGIO2$ST$TCS11Fail$stVal", "ST"), Signal("Healthy state", "AA1C1F13R4Application/ADDGGIO2$ST$TCS1Healthy$stVal", "ST")); @@ -136,12 +136,7 @@ public void PartialLeafReference_DoesNotUseNearTextSimilarity() IedName = "AA1C1F13R4", IpAddress = "192.168.81.17", IedRole = "BCU - 6MD85", - TestPoints = points.Select(point => - { - point.IedName = "AA1C1F13R4"; - point.IpAddress = "192.168.81.17"; - return point; - }).ToList() + TestPoints = points.ToList() }; private static IoTestPointPlan Point(string id, string reference, string fc, string logicalNode) => new() @@ -159,6 +154,23 @@ public void PartialLeafReference_DoesNotUseNearTextSimilarity() TestEnabled = true }; + private static IoTestPointPlan FieldPoint(string id, string reference) => new() + { + TestPointId = id, + IedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + SignalName = id, + ObjectReference = reference, + LogicalDevice = "AA1C1F13R4Application", + LogicalNode = "-", + DataAttribute = "stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "Trip", + ExpectedOffText = "Normal", + ImportReady = true, + TestEnabled = true + }; + private static Iec61850MonitorDevice Device(params SignalDefinition[] signals) { var device = new Iec61850MonitorDevice From e76c7d8cc02462ce462e16aea9e6f0adba9b362c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 09:31:08 +0700 Subject: [PATCH 4/5] Test TCS partial-leaf live binding safety --- .../IoTestLiveBindingServiceTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index 51ab065b7..05ccc597a 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -62,6 +62,54 @@ public void ApplicationFolderHierarchy_IsNormalizedToLiveLnPrefix() Assert.Contains("verified functional-group/LN boundary", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void PartialTcsLeaf_IsBoundUniquelyToDiscoveredSignal() + { + var project = Project(".TCS1Fail"); + var device = Device(); + device.Signals.Add(new SignalDefinition + { + Name = "Trip coil monitoring 1", + ObjectReference = "AA1C1F03R4Application/ADDGGIO2$ST$TCS1Fail$stVal", + FunctionalConstraint = "ST" + }); + + var summary = _binding.Bind(project, new[] { device }); + + Assert.Equal(1, summary.SignalBoundCount); + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.Equal( + "AA1C1F03R4Application/ADDGGIO2$ST$TCS1Fail$stVal", + project.Ieds[0].TestPoints[0].LiveBoundReference); + } + + [Fact] + public void PartialTcsLeaf_DuplicateObjectsRemainBlocked() + { + var project = Project(".TCS1Fail"); + var device = Device(); + device.Signals.Add(new SignalDefinition + { + Name = "Trip coil monitoring 1 ADD", + ObjectReference = "AA1C1F03R4Application/ADDGGIO2$ST$TCS1Fail$stVal", + FunctionalConstraint = "ST" + }); + device.Signals.Add(new SignalDefinition + { + Name = "Trip coil monitoring 1 ALT", + ObjectReference = "AA1C1F03R4Application/ALTGGIO3$ST$TCS1Fail$stVal", + FunctionalConstraint = "ST" + }); + + var summary = _binding.Bind(project, new[] { device }); + + Assert.Equal(0, summary.SignalBoundCount); + Assert.Equal(1, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.SignalNotFound, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.Contains("more than one equally strong", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ActiveLivePoint_PopulatesCurrentEvidencePreview() { From 8b04f4a058dd2ff98bee272197a42ef51cd44c79 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 09:31:49 +0700 Subject: [PATCH 5/5] Fix TCS live binding assertion --- tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index 05ccc597a..de25160c8 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -81,7 +81,7 @@ public void PartialTcsLeaf_IsBoundUniquelyToDiscoveredSignal() Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); Assert.Equal( "AA1C1F03R4Application/ADDGGIO2$ST$TCS1Fail$stVal", - project.Ieds[0].TestPoints[0].LiveBoundReference); + project.Ieds[0].TestPoints[0].LiveSignalReference); } [Fact]