Skip to content
60 changes: 60 additions & 0 deletions MainWindow.NavigationLayoutFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;

namespace ArIED61850Tester;

/// <summary>
/// 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.
/// </summary>
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;
}
}
}
50 changes: 37 additions & 13 deletions MainWindow.RcbExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,28 @@ private static IReadOnlyList<RcbExportRow> 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<RcbExportRow> BuildSourceBackedRcbRows(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -326,13 +345,12 @@ private async Task<RcbExportCompletion> 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,
Expand Down Expand Up @@ -365,16 +383,22 @@ private async Task<RcbExportCompletion> 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(
Expand Down
18 changes: 8 additions & 10 deletions Models/RcbExportModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -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";
Expand All @@ -189,7 +187,7 @@ public void ReplaceRows(IReadOnlyList<RcbExportRow> 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));
}
Expand Down
88 changes: 77 additions & 11 deletions Services/Iec61850ValueFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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() ?? "-"
};
}
Expand Down Expand Up @@ -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]",
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
30 changes: 30 additions & 0 deletions tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public void DuplicateShortRcbNames_AreDistinguishedByLogicalScope()
}

[Fact]
public void EvidenceConflict_BlocksSelectionEvenWhenMembersExist()
public void EvidenceConflict_RemainsSelectableAndRequiresConfirmation()
{
var row = new RcbExportRow
{
Expand All @@ -153,7 +153,8 @@ public void EvidenceConflict_BlocksSelectionEvenWhenMembersExist()
HasEvidenceConflict = true
};

Assert.False(row.IsSelectable);
Assert.True(row.IsSelectable);
Assert.True(row.RequiresConfirmation);
}

[Fact]
Expand Down
Loading