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); } diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index 51ab065b7..de25160c8 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].LiveSignalReference); + } + + [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() { diff --git a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs index 20cd702e4..5f74472ce 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( + 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")); + + 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(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")); + + 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(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")); + + 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,14 @@ 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.ToList() + }; + private static IoTestPointPlan Point(string id, string reference, string fc, string logicalNode) => new() { TestPointId = id, @@ -97,6 +154,23 @@ public void EqualSmartCandidates_RemainAmbiguousInsteadOfBeingGuessed() 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 @@ -110,6 +184,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,