From 52be4b3149ccb5a43beb207de9c44dba6bfbe14a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 09:43:40 +0700 Subject: [PATCH 1/5] FAT: make signal presentation LN/DO/DA context aware --- Models/IoTesting/IoSignalDisplayName.cs | 86 ++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs index 78303a978..7c0b06cea 100644 --- a/Models/IoTesting/IoSignalDisplayName.cs +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -9,22 +9,92 @@ namespace ArIED61850Tester.Models.IoTesting; /// public static partial class IoSignalDisplayName { + private static readonly HashSet OwnerQualifiedLogicalNodeClasses = new(StringComparer.OrdinalIgnoreCase) + { + "XCBR", + "XSWI", + "CSWI", + "CILO" + }; + [GeneratedRegex(@"(?:^|[.$/])phs(?AB|BC|CA|A|B|C)(?:$|[.$/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex PhaseToken(); + [GeneratedRegex(@"(?[A-Z]{4})(?\d*)$", RegexOptions.CultureInvariant)] + private static partial Regex LogicalNodeClassToken(); + public static string Format(string? signalName, string? iecReference) + => Format(signalName, iecReference, logicalNode: null, dataObject: null, dataAttribute: null); + + /// + /// Builds one compact engineering display name from the canonical signal identity. + /// FAT may provide parsed LN/DO/DA metadata while Engineering may provide only a complete + /// IEC reference; both routes intentionally converge on this same presentation rule. + /// + public static string Format( + string? signalName, + string? iecReference, + string? logicalNode, + string? dataObject, + string? dataAttribute) { 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; + // Phase is DA/sub-DO context. FAT imports can retain it either in the canonical + // source reference or in DataAttribute even when a report/event lookup reference + // was normalized to a shorter form. + var phaseSource = string.IsNullOrWhiteSpace(dataAttribute) + ? reference + : reference + "." + dataAttribute.Trim(); + var phaseMatch = PhaseToken().Match(phaseSource); + if (phaseMatch.Success) + { + var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant(); + var suffix = $"Phs{phase}"; + if (!name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) + name = $"{name} {suffix}"; + } + + // Position/control objects are not human-unique without their owning LN class. + // Keep measurement names compact (A PhsA, PhV PhsB, Hz), but qualify switching + // and interlocking objects such as XCBR.Pos and CSWI.Pos. + var ownerClass = ResolveLogicalNodeClass(logicalNode, reference); + if (ownerClass.Length > 0 && + OwnerQualifiedLogicalNodeClasses.Contains(ownerClass) && + !name.StartsWith(ownerClass, StringComparison.OrdinalIgnoreCase)) + { + name = $"{ownerClass} {name}"; + } + + return name; + } + + private static string ResolveLogicalNodeClass(string? logicalNode, string reference) + { + var explicitClass = ExtractLogicalNodeClass(logicalNode); + if (explicitClass.Length > 0) + return explicitClass; + + if (reference.Length == 0) + return string.Empty; + + foreach (var token in reference.Split(['/', '$', '.'], StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = ExtractLogicalNodeClass(token); + if (candidate.Length > 0) + return candidate; + } + + return string.Empty; + } + + private static string ExtractLogicalNodeClass(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; - return $"{name} {suffix}"; + var match = LogicalNodeClassToken().Match(value.Trim().ToUpperInvariant()); + return match.Success ? match.Groups["class"].Value : string.Empty; } } From f6d914086ea45edb46684a0d452ed2b9185b896d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 09:43:55 +0700 Subject: [PATCH 2/5] FAT: use canonical source context for display names --- .../IoFatSignalDisplayNameFormatter.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs b/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs index 4be48bb91..2e58e3cad 100644 --- a/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs +++ b/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs @@ -12,9 +12,28 @@ public static class IoFatSignalDisplayNameFormatter public static string Format(IoTestPointPlan point) { ArgumentNullException.ThrowIfNull(point); - return IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference); + + // Presentation needs the richest original IEC context, not necessarily the reference + // selected for event-log/report lookup. SourceIecReference and DA metadata preserve + // phsA/phsB/phsC and LN ownership even when a lookup alias was normalized shorter. + var semanticReference = FirstNonBlank( + point.SourceIecReference, + point.ReportDisplayReference, + point.ObjectReference, + point.EventLogSearchReference, + point.ReportIecReference); + + return IoSignalDisplayName.Format( + point.SignalName, + semanticReference, + point.LogicalNode, + point.DataObject, + point.DataAttribute); } public static string Format(string? signalName, string? iecReference) => IoSignalDisplayName.Format(signalName, iecReference); + + private static string FirstNonBlank(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; } From 330d0e7596acb28183eac5f3c3d9a4cf877d10d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 09:46:11 +0700 Subject: [PATCH 3/5] FAT: align report signal names with workspace semantics --- Services/IoTesting/IoFatV2ReportLayoutEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs index 90e50a4e8..59cd2f004 100644 --- a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs @@ -189,7 +189,7 @@ private static void DrawPointRow( var cells = new[] { rowNumber.ToString(), - Short(IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference), 26), + Short(IoFatSignalDisplayNameFormatter.Format(point), 26), string.Empty, point.SignalKind.ToString(), ValueCell(point, FatValueSlot.Value1), From a3db8e4ab53d8bd0294668b511112cd61cd1a2a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 09:46:36 +0700 Subject: [PATCH 4/5] tests: lock Engineering/FAT context-aware signal presentation --- ...tAwareSignalPresentationRegressionTests.cs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/ARSAS.Tests/ContextAwareSignalPresentationRegressionTests.cs diff --git a/tests/ARSAS.Tests/ContextAwareSignalPresentationRegressionTests.cs b/tests/ARSAS.Tests/ContextAwareSignalPresentationRegressionTests.cs new file mode 100644 index 000000000..8bb3cc305 --- /dev/null +++ b/tests/ARSAS.Tests/ContextAwareSignalPresentationRegressionTests.cs @@ -0,0 +1,145 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class ContextAwareSignalPresentationRegressionTests +{ + [Theory] + [InlineData("A", "IEDLD/MMXU1.A.phsA.cVal.mag.f", "A PhsA")] + [InlineData("A", "IEDLD/MMXU1.A.phsB.cVal.mag.f", "A PhsB")] + [InlineData("A", "IEDLD/MMXU1.A.phsC.cVal.mag.f", "A PhsC")] + [InlineData("PhV", "IEDLD/MMXU1.PhV.phsA.cVal.mag.f", "PhV PhsA")] + [InlineData("Pos", "IEDLD/XCBR1.Pos.stVal", "XCBR Pos")] + [InlineData("Pos", "IEDLD/CSWI1.Pos.stVal", "CSWI Pos")] + public void SharedEngineeringPresentation_IsPhaseAndOwnerAware( + string signalName, + string reference, + string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference)); + } + + [Fact] + public void FatPresentation_RecoversPhaseFromDaContext_WhenLookupReferenceIsShorter() + { + var point = Point( + signalName: "A", + objectReference: "IEDLD/MMXU1.A.cVal.mag.f", + logicalNode: "MMXU1", + dataObject: "A", + dataAttribute: "phsB.cVal.mag.f", + sourceReference: "IEDLD/MMXU1.A"); + + Assert.Equal("A PhsB", IoFatSignalDisplayNameFormatter.Format(point)); + } + + [Theory] + [InlineData("XCBR1", "XCBR Pos")] + [InlineData("CSWI1", "CSWI Pos")] + public void FatPresentation_QualifiesGenericPositionWithOwningLogicalNode( + string logicalNode, + string expected) + { + var point = Point( + signalName: "Pos", + objectReference: $"IEDLD/{logicalNode}.Pos.stVal", + logicalNode: logicalNode, + dataObject: "Pos", + dataAttribute: "stVal", + sourceReference: $"IEDLD/{logicalNode}.Pos.stVal"); + + Assert.Equal(expected, IoFatSignalDisplayNameFormatter.Format(point)); + } + + [Theory] + [InlineData("A PhsA", "IEDLD/MMXU1.A.phsA.cVal.mag.f", "A PhsA")] + [InlineData("XCBR Pos", "IEDLD/XCBR1.Pos.stVal", "XCBR Pos")] + public void SemanticPresentation_DoesNotDuplicateExistingContext( + string signalName, + string reference, + string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference)); + } + + [Fact] + public void FatReport_UsesTheSamePointAwareSemanticFormatterAsFatWorkspace() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatV2ReportLayoutEngine.cs")); + + Assert.Contains("IoFatSignalDisplayNameFormatter.Format(point)", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference)", + source, + StringComparison.Ordinal); + } + + [Fact] + public void EngineeringAndFat_ConvergeOnTheSameSemanticAuthority() + { + var engineering = File.ReadAllText(FindRepoFile("MainWindow.FieldPresentationFix.cs")); + var fat = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSignalDisplayNameFormatter.cs")); + + Assert.Contains("IoSignalDisplayName.Format(preferred, reference)", engineering, StringComparison.Ordinal); + Assert.Contains("IoSignalDisplayName.Format(", fat, StringComparison.Ordinal); + Assert.Contains("point.SourceIecReference", fat, StringComparison.Ordinal); + Assert.Contains("point.LogicalNode", fat, StringComparison.Ordinal); + Assert.Contains("point.DataObject", fat, StringComparison.Ordinal); + Assert.Contains("point.DataAttribute", fat, StringComparison.Ordinal); + } + + [Fact] + public void FatLiveValueBinding_RemainsCanonicalReferenceBased_NotDisplayNameBased() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestLiveBindingService.cs")); + + Assert.Contains( + "expectedReferences.Contains(NormalizeReference(item.IecReference))", + source, + StringComparison.Ordinal); + Assert.Contains("binding.LivePoint.Value", source, StringComparison.Ordinal); + Assert.Contains("ExactSignalIdentityMatches", source, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName.Equals(", source, StringComparison.Ordinal); + } + + private static IoTestPointPlan Point( + string signalName, + string objectReference, + string logicalNode, + string dataObject, + string dataAttribute, + string sourceReference) + => new() + { + TestPointId = Guid.NewGuid().ToString("N"), + IedName = "IED", + IpAddress = "192.0.2.1", + SignalName = signalName, + ObjectReference = objectReference, + FunctionalConstraint = "ST", + ExpectedOnText = "ON", + ExpectedOffText = "OFF", + LogicalDevice = "IEDLD", + LogicalNode = logicalNode, + DataObject = dataObject, + DataAttribute = dataAttribute, + SourceIecReference = sourceReference, + ReportDisplayReference = sourceReference, + EventLogSearchReference = objectReference + }; + + 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 4019a11f9e33d923047e5b9b57249b4109ea1499 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 09:51:59 +0700 Subject: [PATCH 5/5] FAT: parse logical-node class only from complete IEC tokens --- Models/IoTesting/IoSignalDisplayName.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs index 7c0b06cea..d55346a2a 100644 --- a/Models/IoTesting/IoSignalDisplayName.cs +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -20,7 +20,7 @@ public static partial class IoSignalDisplayName [GeneratedRegex(@"(?:^|[.$/])phs(?AB|BC|CA|A|B|C)(?:$|[.$/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex PhaseToken(); - [GeneratedRegex(@"(?[A-Z]{4})(?\d*)$", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"^(?[A-Z]{4})(?\d*)$", RegexOptions.CultureInvariant)] private static partial Regex LogicalNodeClassToken(); public static string Format(string? signalName, string? iecReference)