Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions Services/IoTesting/IoTestReferenceMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ namespace ArIED61850Tester.Services.IoTesting;
/// <summary>
/// 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.
/// </summary>
internal static class IoTestReferenceMatcher
{
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
48 changes: 48 additions & 0 deletions tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
87 changes: 87 additions & 0 deletions tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
Loading