From 5b4aff635b0730e5d4c3fac5c2a0c5c1e88fe90d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 08:05:59 +0700 Subject: [PATCH 1/6] feat: replace legacy IED fascia artwork --- Assets/RelayFascia.svg | 199 +++++++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 45 deletions(-) diff --git a/Assets/RelayFascia.svg b/Assets/RelayFascia.svg index a5302ad8b..9a69c173b 100644 --- a/Assets/RelayFascia.svg +++ b/Assets/RelayFascia.svg @@ -1,47 +1,156 @@ - - - - - Layer 1 - - - - - - - - - - - - - - - - - - + + + ARSAS Premium IED Icon + Premium vector icon of a protection relay front fascia for ARSAS IED cards. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ARSAS + IED PREMIUM + P618 + PROTECTION + + + + + + + + BAY 1 + 33kV + + + + + + + + + + + TRIP + + ALM + + PKP + + + + + + + + + + + + OK + - - - - - - - - - - - - - - - - - - - - - - From d0266657514656f0e4561d77a7c72ac3e44f3bca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 08:06:56 +0700 Subject: [PATCH 2/6] feat: drive IED fascia lamps from connection state --- Resources/ArvrelMiniIedFascia.xaml | 287 ++++++++++++++++++++++------- 1 file changed, 219 insertions(+), 68 deletions(-) diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml index bbe693f8b..cb4b61c61 100644 --- a/Resources/ArvrelMiniIedFascia.xaml +++ b/Resources/ArvrelMiniIedFascia.xaml @@ -3,84 +3,235 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + From de9987391d4c119a9fc3bb9af0a31f79c88bbeb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 10:33:36 +0700 Subject: [PATCH 3/6] fix: drive fascia LEDs across explorer and FAT cards --- Resources/ArvrelMiniIedFascia.xaml | 20 +++++++++++++++++++ tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs | 12 +++++------ .../P1RelayBenchHotfixRegressionTests.cs | 12 +++++------ .../P2BlueSteelGreigeThemeTests.cs | 13 ++++++------ 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml index cb4b61c61..e9d3e2aa9 100644 --- a/Resources/ArvrelMiniIedFascia.xaml +++ b/Resources/ArvrelMiniIedFascia.xaml @@ -56,6 +56,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs index 775a690d2..6230cbc92 100644 --- a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs +++ b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs @@ -27,13 +27,12 @@ public void CompactArvrelFascia_IsVectorOnlyAndKeepsRecognizableRelayHardware() .Cast() .ToHashSet(StringComparer.Ordinal); - Assert.Contains("RelayFasciaArtwork", namedParts); Assert.Contains("RelayStateRail", namedParts); // The operator-supplied SVG is transcribed to native WPF vector primitives so no // raster/image dependency is introduced at card scale. Assert.Empty(template.Descendants(presentation + "Image")); - Assert.Empty(template.Descendants(presentation + "TextBlock")); + Assert.NotEmpty(template.Descendants(presentation + "TextBlock")); Assert.NotEmpty(template.Descendants(presentation + "Rectangle")); Assert.NotEmpty(template.Descendants(presentation + "Ellipse")); Assert.NotEmpty(template.Descendants(presentation + "Line")); @@ -41,10 +40,11 @@ public void CompactArvrelFascia_IsVectorOnlyAndKeepsRecognizableRelayHardware() var templateText = template.ToString(); Assert.Contains("{TemplateBinding Foreground}", templateText, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#F2F2F2", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#00FF7F", templateText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ArsasIedConnectionLed", document.ToString(), StringComparison.Ordinal); + Assert.Contains("{Binding IsConnected}", document.ToString(), StringComparison.Ordinal); + Assert.Contains("{Binding IsLiveConnected}", document.ToString(), StringComparison.Ordinal); + Assert.Contains("#FF5538", document.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", document.ToString(), StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs index 7d10c9926..f858b8570 100644 --- a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs +++ b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs @@ -76,13 +76,13 @@ public void RelayFascia_UsesUploadedSvgArtworkAsVectorSource() var fascia = Read("Resources/ArvrelMiniIedFascia.xaml"); var svg = Read("Assets/RelayFascia.svg"); - Assert.Contains("direct WPF vector transcription", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("x:Name=\"RelayFasciaArtwork\"", fascia, StringComparison.Ordinal); + Assert.Contains("WPF vector runtime counterpart", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("x:Key=\"ArsasIedConnectionLed\"", fascia, StringComparison.Ordinal); Assert.Contains("x:Name=\"RelayStateRail\"", fascia, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("id=\"svg_1\"", svg, StringComparison.Ordinal); - Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); + Assert.Contains("#FF5538", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ARSAS Premium IED Icon", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledGreen\"", svg, StringComparison.Ordinal); } private static string Read(string relativePath) diff --git a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs index 82a3c85d3..930897d0e 100644 --- a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs +++ b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs @@ -72,15 +72,14 @@ public void RelayFascia_UsesOperatorSuppliedSvgVectorAndKeepsSemanticStateRail() var source = File.ReadAllText(FindRepoFile("Resources/ArvrelMiniIedFascia.xaml")); var svg = File.ReadAllText(FindRepoFile("Assets/RelayFascia.svg")); - Assert.Contains("RelayFasciaArtwork", source, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#F2F2F2", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ArsasIedConnectionLed", source, StringComparison.Ordinal); + Assert.Contains("#FF5538", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("{TemplateBinding Foreground}", source, StringComparison.Ordinal); Assert.Contains("RelayStateRail", source, StringComparison.Ordinal); - Assert.Contains("width=\"424.99999999999994\"", svg, StringComparison.Ordinal); - Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); - Assert.Contains("#00ff7f", svg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("width=\"256\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledRed\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledGreen\"", svg, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath) From eaa8e96e1e43476e4f54b333bd3cc229d6e28d57 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 14:07:16 +0700 Subject: [PATCH 4/6] 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..e0130642f 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(new[] { '.', '$', '[', '(' }); + 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(new[] { '.', '$', '[', '(' }); + 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 728d3912a82f25cc5096844a0c8f4b3188f153ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 14:07:40 +0700 Subject: [PATCH 5/6] fix: apply context-aware names in FAT dataset projection --- 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 7fca328b7e50660e25e24482c8239013ba7bf679 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 14:07:54 +0700 Subject: [PATCH 6/6] test: cover context-aware IEC 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)); + } +}