From 3842d26551a568b012421de303d7b4de679e2304 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:04:35 +0700 Subject: [PATCH 1/8] Normalize scalar leaf values from report structures --- Services/Iec61850ValueFormatter.cs | 88 ++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index d629f29a2..fb45b3fb4 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -7,12 +7,12 @@ public static class Iec61850ValueFormatter { public static string Format(object? value, string dataType, string unit) { - // Compatibility guard for ARIEC61850 builds that still expose an SPS-like - // DataObject report value as one rendered CDC structure instead of the - // projected stVal leaf. Keep this deliberately narrow: stVal must be the - // first named field and must be a Boolean. Other structures remain intact. - if (TryExtractStructuredBooleanStVal(value, out var structuredStVal)) - value = structuredStVal; + // Compatibility guard for report engines/IEDs that expose a scalar leaf as + // its enclosing CDC structure. Named stVal is authoritative regardless of + // scalar type. An indexed first child is collapsed only when signal metadata + // already says the selected leaf is numeric (e.g. BCR actVal). + if (TryExtractStructuredScalar(value, dataType, out var structuredScalar)) + value = structuredScalar; if (IsDbposDataType(dataType) && TryNormalizeDbpos(value, out var dbpos)) return FormatDbpos(dbpos); @@ -38,6 +38,7 @@ int i when dataType.Equals("Enum", StringComparison.OrdinalIgnoreCase) && i == 2 dt, "yyyy-MM-dd HH:mm:ss.fff"), DateTimeOffset dto => global::ArIED61850Tester.Iec61850TimestampPresentation.FormatMilliseconds( dto, "yyyy-MM-dd HH:mm:ss.fff zzz"), + string text when TryParseInvariantNumber(text, out var numericText) => AppendUnit(numericText, unit), _ => value.ToString() ?? "-" }; } @@ -66,6 +67,18 @@ private static bool IsDbposDataType(string dataType) dataType.Equals("DPC", StringComparison.OrdinalIgnoreCase) || dataType.Equals("DoublePointStatus", StringComparison.OrdinalIgnoreCase); + private static bool IsNumericDataType(string dataType) + { + var normalized = (dataType ?? string.Empty).Trim().ToLowerInvariant(); + return normalized.Contains("int", StringComparison.Ordinal) || + normalized.Contains("uint", StringComparison.Ordinal) || + normalized.Contains("float", StringComparison.Ordinal) || + normalized.Contains("double", StringComparison.Ordinal) || + normalized.Contains("decimal", StringComparison.Ordinal) || + normalized.Contains("counter", StringComparison.Ordinal) || + normalized.Contains("bcr", StringComparison.Ordinal); + } + private static string FormatDbpos(int code) => code switch { 0 => "Intermediate [00]", @@ -122,17 +135,70 @@ private static bool TryParseDbposText(string text, out int code) } } - private static bool TryExtractStructuredBooleanStVal(object? value, out bool status) + private static bool TryExtractStructuredScalar(object? value, string dataType, out object? scalar) { - status = false; + scalar = null; if (value is not string text || string.IsNullOrWhiteSpace(text)) return false; - var match = Regex.Match( + var stVal = Regex.Match( + text, + @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*stVal\s*=\s*([^,}]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (stVal.Success) + return TryParseRenderedScalar(stVal.Groups[1].Value, out scalar); + + if (!IsNumericDataType(dataType)) + return false; + + var indexed = Regex.Match( text, - @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*stVal\s*=\s*(true|false)\b", + @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*\[0\]\s*=\s*([^,}]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return match.Success && bool.TryParse(match.Groups[1].Value, out status); + return indexed.Success && TryParseRenderedScalar(indexed.Groups[1].Value, out scalar); + } + + private static bool TryParseRenderedScalar(string raw, out object? scalar) + { + scalar = null; + var text = raw.Trim(); + if (bool.TryParse(text, out var boolean)) + { + scalar = boolean; + return true; + } + + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) + { + scalar = integer; + return true; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var floating)) + { + scalar = floating; + return true; + } + + // Keep bit-string text intact so Dbpos decoding can interpret it safely. + if (text.StartsWith("bits(", StringComparison.OrdinalIgnoreCase)) + { + scalar = text; + return true; + } + + return false; + } + + private static bool TryParseInvariantNumber(string text, out string formatted) + { + formatted = string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return false; + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + return false; + formatted = number.ToString("0.######", CultureInfo.InvariantCulture); + return true; } private static bool TryParseBits(string bits, out int code) From 4886acdbb1bb84b4d98a8756ac435f149c40f8e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:04:59 +0700 Subject: [PATCH 2/8] Make RCB export selection availability-independent --- Models/RcbExportModels.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Models/RcbExportModels.cs b/Models/RcbExportModels.cs index 31f8618ad..2d4417a28 100644 --- a/Models/RcbExportModels.cs +++ b/Models/RcbExportModels.cs @@ -61,14 +61,12 @@ public MmsRcbOperationalAvailability Availability public string Reason { get => _reason; set => Set(ref _reason, value?.Trim() ?? string.Empty); } public string Owner { get => _owner; set => Set(ref _owner, value?.Trim() ?? string.Empty); } - public bool IsSelectable => !HasEvidenceConflict && MemberCount > 0 && Availability is - MmsRcbOperationalAvailability.Available or - MmsRcbOperationalAvailability.UsedByCaller or - MmsRcbOperationalAvailability.Unknown; + // Availability/ownership is evidence for the operator, not an export lock. + // Every discovered RCB remains selectable so the exported engineering model + // can truthfully represent what the IED exposes, including InUse/NoDataSet. + public bool IsSelectable => true; - public bool RequiresConfirmation => !HasEvidenceConflict && Availability is - MmsRcbOperationalAvailability.Unknown or - MmsRcbOperationalAvailability.UsedByCaller; + public bool RequiresConfirmation => HasEvidenceConflict || Availability is not MmsRcbOperationalAvailability.Available; public string MemberCountText => MemberCount > 0 ? $"{MemberCount:N0} FCDA" : "0 FCDA"; public string StatusGlyph => Availability switch @@ -156,7 +154,7 @@ public RcbExportFilterViewModel(RcbExportWindowOptions options) public Visibility MockBadgeVisibility => Options.IsMock ? Visibility.Visible : Visibility.Collapsed; public string SafetyText => Options.IsMock ? "Read-only availability mock — no RCB is reserved or modified" - : "Read-only availability check — ARSAS never reserves or modifies an RCB in this window"; + : "Read-only availability check — status is informational and never hides or locks an RCB from export"; public RcbExportRow? SelectedRow { @@ -170,7 +168,7 @@ public RcbExportRow? SelectedRow } public string AvailabilityCheckedText { get => _availabilityCheckedText; set => Set(ref _availabilityCheckedText, value ?? string.Empty); } - public bool CanExport => SelectedRow?.IsSelectable == true; + public bool CanExport => SelectedRow != null; public string SelectionSummary => SelectedRow == null ? "No RCB selected" : $"{SelectedRow.Name} • {SelectedRow.ScopeText} • {SelectedRow.Type} • {SelectedRow.DataSetName} • {SelectedRow.MemberCount:N0} members"; @@ -189,7 +187,7 @@ public void ReplaceRows(IReadOnlyList rows) var previous = SelectedRow?.SelectionIdentity; Rows.Clear(); foreach (var row in SortRows(rows)) Rows.Add(row); - var restored = Rows.FirstOrDefault(row => row.IsSelectable && row.SelectionIdentity.Equals(previous, StringComparison.OrdinalIgnoreCase)); + var restored = Rows.FirstOrDefault(row => row.SelectionIdentity.Equals(previous, StringComparison.OrdinalIgnoreCase)); SelectOnly(restored); Raise(nameof(SelectionSummary)); Raise(nameof(RemovalSummary)); Raise(nameof(CanExport)); } From aa10fe34c347437fcb5a8a821dc71c0965084d48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:05:48 +0700 Subject: [PATCH 3/8] Show and export complete RCB inventory without availability locks --- MainWindow.RcbExport.cs | 50 ++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 2151abc7a..44c95f25d 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -78,9 +78,28 @@ private static IReadOnlyList BuildRcbExportRows( SclReportControlInventoryResult? sourceInventory, MmsRcbAvailabilityResult? availability) { - if (sourceInventory != null) - return BuildSourceBackedRcbRows(device, sourceInventory, availability); - return BuildLiveModelRcbRows(device.LiveDiscoveryModel, availability); + var liveRows = BuildLiveModelRcbRows(device.LiveDiscoveryModel, availability); + if (sourceInventory == null) + return liveRows; + + // Never let an older/source SCL hide RCBs that the connected IED actually + // exposes. Source-backed rows are preferred for exact export identity, then + // unmatched live-discovery rows are appended as first-class export choices. + var rows = BuildSourceBackedRcbRows(device, sourceInventory, availability).ToList(); + var seen = rows + .Select(row => NormalizeRcbReference(row.Reference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var liveRow in liveRows) + { + var key = NormalizeRcbReference(liveRow.Reference); + if (!seen.Add(key)) + continue; + rows.Add(liveRow); + } + + return rows; } private static IReadOnlyList BuildSourceBackedRcbRows( @@ -144,7 +163,7 @@ private static RcbExportRow CreateSourceBackedRow( ? $"{descriptor.LogicalDeviceInstance} / {descriptor.LogicalNodePath}".Trim(' ', '/') : RcbExportEvidencePolicy.ScopeFromReference(snapshot?.Reference ?? descriptor.DisplayReference); var reason = evidenceConflict - ? $"Configuration mismatch: source SCL binds {RcbExportEvidencePolicy.DisplayBinding(descriptor.DataSetReference)}, while the live IED reports {RcbExportEvidencePolicy.DisplayBinding(snapshot!.DataSetReference)}. Export is blocked until the mismatch is resolved." + ? $"Configuration mismatch: source SCL binds {RcbExportEvidencePolicy.DisplayBinding(descriptor.DataSetReference)}, while the live IED reports {RcbExportEvidencePolicy.DisplayBinding(snapshot!.DataSetReference)}. Export remains available; this mismatch is preserved as operator evidence." : snapshot?.Reason ?? RcbExportEvidencePolicy.SourceReason( descriptor.DataSetName, descriptor.DataSetResolved, @@ -326,13 +345,12 @@ private async Task ExportLegacySasRcbAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (row.HasEvidenceConflict) - throw new InvalidOperationException("Source SCL and live IED DataSet bindings conflict. Resolve the configuration mismatch before exporting this RCB."); - if (row.MemberCount <= 0) - throw new InvalidOperationException("The selected RCB has no populated DataSet."); if (row.IsSourceBacked && !string.IsNullOrWhiteSpace(device.SclSourcePath) && File.Exists(device.SclSourcePath)) { + if (row.HasEvidenceConflict) + AddLog("WARN", "RCB Export", $"{device.Name}: exporting source-backed RCB {row.Reference} with source/live DataSet mismatch preserved as evidence."); + var result = await Task.Run(() => LegacySasSclExporter.WriteFiles( device.SclSourcePath, outputPath, @@ -365,16 +383,22 @@ private async Task ExportLegacySasRcbAsync( var liveModel = device.LiveDiscoveryModel ?? throw new InvalidOperationException("A source SCL file or complete live discovery model is required for legacy SAS export."); - var selectedDataSet = liveModel.DataSets.FirstOrDefault(dataSet => - NormalizeRcbReference(dataSet.Reference) - .Equals(NormalizeRcbReference(row.DataSetReference), StringComparison.OrdinalIgnoreCase)); + var selectedDataSet = string.IsNullOrWhiteSpace(row.DataSetReference) + ? null + : liveModel.DataSets.FirstOrDefault(dataSet => + NormalizeRcbReference(dataSet.Reference) + .Equals(NormalizeRcbReference(row.DataSetReference), StringComparison.OrdinalIgnoreCase)); var exportModel = liveModel; - if (selectedDataSet is null || selectedDataSet.Members.Count == 0) + + // An RCB with no DataSet is still a real RCB and must remain exportable. + // Only request FCDA evidence when the RCB actually declares a DataSet. + if (!string.IsNullOrWhiteSpace(row.DataSetReference) && + (selectedDataSet is null || selectedDataSet.Members.Count == 0)) { if (availability is null) { throw new InvalidOperationException( - "The live discovery model does not contain FCDA member references for this DataSet. Click Check Availability, wait for the read-only audit to finish, then export again."); + "The selected RCB declares a DataSet, but live discovery has no FCDA directory evidence yet. Click Check Availability, wait for the read-only audit to finish, then export again."); } exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( From 66741b25c643dc33a8ba5c1f7f4356bde577669f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:06:28 +0700 Subject: [PATCH 4/8] Prevent workflow tab buttons from clipping at bottom edge --- MainWindow.NavigationLayoutFix.cs | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 MainWindow.NavigationLayoutFix.cs diff --git a/MainWindow.NavigationLayoutFix.cs b/MainWindow.NavigationLayoutFix.cs new file mode 100644 index 000000000..b666252b9 --- /dev/null +++ b/MainWindow.NavigationLayoutFix.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; + +namespace ArIED61850Tester; + +/// +/// Keeps the compact workflow navigation vertically centered at Windows/DPI text +/// metrics where the old fixed 56 px shell could clip the lower button edge. +/// Scoped to MainWindow navigation only; no global Button style is changed. +/// +internal static class MainWindowNavigationLayoutFix +{ + [ModuleInitializer] + internal static void Register() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnMainWindowLoaded)); + } + + private static void OnMainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window) + return; + + if (window.FindName("WorkflowNavShell") is Border shell) + { + shell.Height = 60; + shell.Padding = new Thickness(5, 6, 5, 6); + shell.ClipToBounds = false; + } + + if (window.FindName("WorkflowPill") is Border pill) + { + pill.Height = 36; + pill.VerticalAlignment = VerticalAlignment.Center; + } + + foreach (var name in new[] + { + "NavExplorerButton", + "NavLiveButton", + "NavEventsButton", + "NavGooseButton", + "NavDiagnosticsButton" + }) + { + if (window.FindName(name) is not Button button) + continue; + + button.MinHeight = 40; + button.Margin = new Thickness(1, 1, 1, 1); + button.VerticalAlignment = VerticalAlignment.Stretch; + button.VerticalContentAlignment = VerticalAlignment.Center; + button.ClipToBounds = false; + } + } +} From 9418baea5a992ab92b665a9176c735a5e5623e39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:07:13 +0700 Subject: [PATCH 5/8] Cover structured counter and BCR scalar formatting --- .../Iec61850ValueFormatterTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index ddbe66b60..15aa43b22 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -24,6 +24,36 @@ public void Format_Extracts_True_Boolean_StVal_From_Legacy_Report_Structure() Assert.Equal("True", formatted); } + [Fact] + public void Format_Extracts_Integer_StVal_From_Counter_Status_Structure() + { + const string value = "Structure(3) {stVal=0, q=Quality{V=1,D=0}, t=BinaryTime(2026-08-15 16:11:57.723, Q=0x0A, ext=True)}"; + + var formatted = Iec61850ValueFormatter.Format(value, "Int32", string.Empty); + + Assert.Equal("0", formatted); + } + + [Fact] + public void Format_Extracts_Indexed_Numeric_ActVal_From_Bcr_Structure_When_Metadata_Is_Numeric() + { + const string value = "Structure(5) {[0]=12345, [1]=Quality{V=1,D=0}, [2]=BinaryTime(2026-08-15 16:11:57.723), [3]=false, [4]=BinaryTime(2026-08-15 16:11:00.000)}"; + + var formatted = Iec61850ValueFormatter.Format(value, "Int64", "Wh"); + + Assert.Equal("12345 Wh", formatted); + } + + [Fact] + public void Format_Does_Not_Collapse_Indexed_Structure_When_Metadata_Is_Not_Numeric() + { + const string value = "Structure(5) {[0]=12345, [1]=Quality{V=1,D=0}, [2]=BinaryTime(2026-08-15 16:11:57.723), [3]=false, [4]=BinaryTime(2026-08-15 16:11:00.000)}"; + + var formatted = Iec61850ValueFormatter.Format(value, "Structure", string.Empty); + + Assert.Equal(value, formatted); + } + [Fact] public void Format_Does_Not_Collapse_NonStVal_Structures() { From 39fb6dccd7497b0f5d3f495d7ae52538d3c1df0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:09:36 +0700 Subject: [PATCH 6/8] Pin ARSAS to merged DataRef alignment engine --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index ed6a1666a..0662ab095 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "1ed53ab3252902f680d76adbadd25790bf6f8ca4", - "sourcePullRequest": 79, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, and PR #79 projects generic Boolean status structures {stVal, q, t} to the scalar stVal leaf while preserving quality/timestamp and raw-structure diagnostics for unrecognized non-Boolean structures." + "commit": "e23b295b87760be8f7f0ce978a6987027ea50523", + "sourcePullRequest": 80, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, and PR #80 normalizes validated DataRef-enabled InformationReport ordering so metadata cannot shift into process values." } From 5a6a52aa1f0eec60e2996709959553c5dc8f57da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 08:09:56 +0700 Subject: [PATCH 7/8] Advance immutable engine regression pin to PR 80 merge --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index 1f9c43e43..ff13b7a0a 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -72,10 +72,11 @@ public void EngineLock_PinsMergedReportProjectionEngineWithoutLosingMemberCentri { var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); - Assert.Contains("1ed53ab3252902f680d76adbadd25790bf6f8ca4", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 79", source, StringComparison.Ordinal); + Assert.Contains("e23b295b87760be8f7f0ce978a6987027ea50523", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 80", source, StringComparison.Ordinal); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); } [Fact] From 608843a1dbc93231f20377b3e697f4a3e7571f00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 10:13:48 +0700 Subject: [PATCH 8/8] Update RCB conflict regression for selectable export --- tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs b/tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs index 3bb0e2d1d..319aec440 100644 --- a/tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs +++ b/tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs @@ -139,7 +139,7 @@ public void DuplicateShortRcbNames_AreDistinguishedByLogicalScope() } [Fact] - public void EvidenceConflict_BlocksSelectionEvenWhenMembersExist() + public void EvidenceConflict_RemainsSelectableAndRequiresConfirmation() { var row = new RcbExportRow { @@ -153,7 +153,8 @@ public void EvidenceConflict_BlocksSelectionEvenWhenMembersExist() HasEvidenceConflict = true }; - Assert.False(row.IsSelectable); + Assert.True(row.IsSelectable); + Assert.True(row.RequiresConfirmation); } [Fact]