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;
+ }
+ }
+}
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(
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));
}
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)
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."
}
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()
{
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]
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]