From b3ac3642ba781aad93b2ccc54f49581ae1453f99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 10:37:33 +0700 Subject: [PATCH 1/4] fix: make IEC signal display names context aware --- Models/IoTesting/IoSignalDisplayName.cs | 133 ++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 8 deletions(-) diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs index 78303a978..6a1a838f1 100644 --- a/Models/IoTesting/IoSignalDisplayName.cs +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -16,15 +16,132 @@ public static string Format(string? signalName, string? iecReference) { var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim(); var reference = iecReference?.Trim() ?? string.Empty; - var match = PhaseToken().Match(reference); - if (!match.Success) - return name; - var phase = match.Groups["phase"].Value.ToUpperInvariant(); - var suffix = $"Phs{phase}"; - if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) - return name; + var phaseMatch = PhaseToken().Match(reference); + if (phaseMatch.Success) + { + var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant(); + var suffix = $"Phs{phase}"; + if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) + return name; - return $"{name} {suffix}"; + return $"{name} {suffix}"; + } + + if (TryExtractLogicalNodeContext(reference, out var logicalNodeClass, out var dataObject) + && RequiresLogicalNodeOwner(dataObject) + && name.Equals(dataObject, StringComparison.OrdinalIgnoreCase)) + { + return $"{logicalNodeClass} {name}"; + } + + return name; + } + + private static bool TryExtractLogicalNodeContext( + string reference, + out string logicalNodeClass, + out string dataObject) + { + logicalNodeClass = string.Empty; + dataObject = string.Empty; + + if (string.IsNullOrWhiteSpace(reference)) + return false; + + var slashIndex = reference.LastIndexOf('/'); + var tail = slashIndex >= 0 && slashIndex + 1 < reference.Length + ? reference[(slashIndex + 1)..] + : reference; + + var dotIndex = tail.IndexOf('.'); + var dollarIndex = tail.IndexOf('$'); + var separatorIndex = dotIndex switch + { + >= 0 when dollarIndex >= 0 => Math.Min(dotIndex, dollarIndex), + >= 0 => dotIndex, + _ => dollarIndex + }; + + if (separatorIndex <= 0 || separatorIndex + 1 >= tail.Length) + return false; + + var logicalNodeToken = tail[..separatorIndex].Trim(); + if (!TryExtractLogicalNodeClass(logicalNodeToken, out logicalNodeClass)) + return false; + + var separator = tail[separatorIndex]; + var remainder = tail[(separatorIndex + 1)..]; + if (separator == '$') + { + var tokens = remainder.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length == 0) + return false; + + var dataObjectIndex = IsFunctionalConstraint(tokens[0]) ? 1 : 0; + if (dataObjectIndex >= tokens.Length) + return false; + + dataObject = NormalizeDataObjectToken(tokens[dataObjectIndex]); + } + else + { + var tokenEnd = remainder.IndexOfAny('.', '$', '[', '('); + var token = tokenEnd >= 0 ? remainder[..tokenEnd] : remainder; + dataObject = NormalizeDataObjectToken(token); + } + + return !string.IsNullOrWhiteSpace(dataObject); } + + private static bool TryExtractLogicalNodeClass(string logicalNodeToken, out string logicalNodeClass) + { + logicalNodeClass = string.Empty; + if (string.IsNullOrWhiteSpace(logicalNodeToken)) + return false; + + var token = logicalNodeToken.Trim(); + if (token.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) + { + logicalNodeClass = "LLN0"; + return true; + } + + var end = token.Length; + while (end > 0 && char.IsDigit(token[end - 1])) + end--; + + if (end == 0) + return false; + + var withoutInstance = token[..end]; + if (withoutInstance.Length >= 4) + { + var candidate = withoutInstance[^4..]; + if (candidate.All(char.IsLetter)) + { + logicalNodeClass = candidate.ToUpperInvariant(); + return true; + } + } + + logicalNodeClass = withoutInstance.ToUpperInvariant(); + return true; + } + + private static string NormalizeDataObjectToken(string token) + { + var value = token.Trim(); + var end = value.IndexOfAny('.', '$', '[', '('); + return (end >= 0 ? value[..end] : value).Trim(); + } + + private static bool RequiresLogicalNodeOwner(string dataObject) + => dataObject.ToUpperInvariant() is "POS" or "MOD" or "BEH" or "HEALTH" or "LOC" or "OPCNT"; + + private static bool IsFunctionalConstraint(string token) + => token.ToUpperInvariant() is + "ST" or "MX" or "CO" or "SP" or "SV" or "CF" or "DC" or "SG" or "SE" or + "SR" or "OR" or "BL" or "EX" or "RP" or "BR" or "LG" or "GO" or "GS" or + "MS" or "US"; } From 9e39fe631dbd0adb7a87e840c30b54511e4a4bb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 10:37:56 +0700 Subject: [PATCH 2/4] fix: apply canonical display names to FAT dataset rows --- Services/IoTesting/FatDataSetSignalProjectionService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/FatDataSetSignalProjectionService.cs b/Services/IoTesting/FatDataSetSignalProjectionService.cs index 1b78c2072..fef7620ae 100644 --- a/Services/IoTesting/FatDataSetSignalProjectionService.cs +++ b/Services/IoTesting/FatDataSetSignalProjectionService.cs @@ -79,6 +79,8 @@ private static FatVerificationSignal CreateRow( staticReference); var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); var signalKind = Classify(descriptor.FunctionalConstraint, dataType); + var rawSignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference); + var displayReference = FirstNonEmpty(runtimeReference, staticReference); return new FatVerificationSignal { @@ -94,7 +96,7 @@ private static FatVerificationSignal CreateRow( DataSetMemberIndex = membership.MemberIndex, StaticMemberReference = staticReference, RuntimeReference = runtimeReference, - SignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference), + SignalName = IoFatSignalDisplayNameFormatter.Format(rawSignalName, displayReference), FunctionalConstraint = descriptor.FunctionalConstraint, DataType = dataType, SignalKind = signalKind, From 69cc97022aba980b73637430124a48a82c01aa62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 10:38:13 +0700 Subject: [PATCH 3/4] test: cover IEC context-aware signal display names --- tests/ARSAS.Tests/IoSignalDisplayNameTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/ARSAS.Tests/IoSignalDisplayNameTests.cs diff --git a/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs b/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs new file mode 100644 index 000000000..041f2308a --- /dev/null +++ b/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs @@ -0,0 +1,68 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoSignalDisplayNameTests +{ + [Theory] + [InlineData("IED1MEAS/MMXU1.A.phsA.cVal.mag.f", "A PhsA")] + [InlineData("IED1MEAS/MMXU1.A.phsB.cVal.mag.f", "A PhsB")] + [InlineData("IED1MEAS/MMXU1.A.phsC.cVal.mag.f", "A PhsC")] + public void Format_AppendsPhaseContextWithoutAddingMeasurementOwner(string reference, string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format("A", reference)); + } + + [Theory] + [InlineData("IED1CTRL/XCBR1.Pos.stVal", "XCBR Pos")] + [InlineData("IED1CTRL/CSWI1.Pos.stVal", "CSWI Pos")] + [InlineData("IED1CTRL/XCBR1$ST$Pos$stVal", "XCBR Pos")] + public void Format_QualifiesAmbiguousDataObjectWithLogicalNodeOwner(string reference, string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format("Pos", reference)); + } + + [Theory] + [InlineData("Mod", "IED1CTRL/XCBR1.Mod.stVal", "XCBR Mod")] + [InlineData("Beh", "IED1CTRL/CSWI1.Beh.stVal", "CSWI Beh")] + [InlineData("Health", "IED1CTRL/LPHD1.Health.stVal", "LPHD Health")] + [InlineData("Loc", "IED1CTRL/CSWI1.Loc.stVal", "CSWI Loc")] + [InlineData("OpCnt", "IED1CTRL/XCBR1.OpCnt.stVal", "XCBR OpCnt")] + public void Format_UsesSameOwnerRuleForOtherGenericIecDataObjects( + string signalName, + string reference, + string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference)); + } + + [Fact] + public void Format_DoesNotDuplicateExistingOwnerContext() + { + Assert.Equal( + "XCBR Pos", + IoSignalDisplayName.Format("XCBR Pos", "IED1CTRL/XCBR1.Pos.stVal")); + } + + [Fact] + public void Format_LeavesSpecificDataObjectNameConcise() + { + Assert.Equal( + "Dig01", + IoSignalDisplayName.Format("Dig01", "IED1ADD/GGIO1.Dig01.stVal")); + } + + [Fact] + public void Format_LeavesCustomLabelUntouched() + { + Assert.Equal( + "Breaker position", + IoSignalDisplayName.Format("Breaker position", "IED1CTRL/XCBR1.Pos.stVal")); + } + + [Fact] + public void Format_FallsBackGracefullyWithoutReference() + { + Assert.Equal("Pos", IoSignalDisplayName.Format("Pos", null)); + } +} From dc430a69b3ee5629d32c991eb15004ce4e4aafbd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 11:01:36 +0700 Subject: [PATCH 4/4] fix: use .NET 8 compatible IndexOfAny overload --- Models/IoTesting/IoSignalDisplayName.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs index 6a1a838f1..e0130642f 100644 --- a/Models/IoTesting/IoSignalDisplayName.cs +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -86,7 +86,7 @@ private static bool TryExtractLogicalNodeContext( } else { - var tokenEnd = remainder.IndexOfAny('.', '$', '[', '('); + var tokenEnd = remainder.IndexOfAny(new[] { '.', '$', '[', '(' }); var token = tokenEnd >= 0 ? remainder[..tokenEnd] : remainder; dataObject = NormalizeDataObjectToken(token); } @@ -132,7 +132,7 @@ private static bool TryExtractLogicalNodeClass(string logicalNodeToken, out stri private static string NormalizeDataObjectToken(string token) { var value = token.Trim(); - var end = value.IndexOfAny('.', '$', '[', '('); + var end = value.IndexOfAny(new[] { '.', '$', '[', '(' }); return (end >= 0 ? value[..end] : value).Trim(); }