diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs
index 981d69b46..844f7476b 100644
--- a/MainWindow.GooseSubscriber.cs
+++ b/MainWindow.GooseSubscriber.cs
@@ -796,7 +796,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot(
streamEvent.ChangedValueCount,
frame.Pdu.Test,
frame.Pdu.NeedsCommissioning,
- leaves);
+ leaves,
+ frame.Vlan is { } tagged ? tagged.VlanId.ToString(CultureInfo.InvariantCulture) : "Untagged",
+ frame.Vlan is { } priority ? priority.PriorityCodePoint.ToString(CultureInfo.InvariantCulture) : "—",
+ rawValueCount);
}
private static string FirstNonEmpty(params string?[] values)
diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs
index d57a3cfde..61e0a6ba8 100644
--- a/MainWindow.GooseTimeline.cs
+++ b/MainWindow.GooseTimeline.cs
@@ -324,6 +324,8 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured,
EventText = eventText,
EventTone = tone,
Publisher = BuildGoosePublisherName(stream),
+ IedName = string.IsNullOrWhiteSpace(stream.ModelIedName) ? "Unresolved" : stream.ModelIedName,
+ GooseId = string.IsNullOrWhiteSpace(stream.GoId) ? "Not provided" : stream.GoId,
StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}",
SourceMac = stream.SourceMac,
DestinationMac = stream.DestinationMac,
diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs
new file mode 100644
index 000000000..90226b39d
--- /dev/null
+++ b/MainWindow.IecNaturalSort.cs
@@ -0,0 +1,41 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+///
+/// Natural IEC sorting is attached to the actual native seven-column grid.
+/// The earlier search-module heuristic expected six columns and never ran.
+/// This changes only the WPF collection view, not the report/DataSet order.
+///
+public partial class MainWindow
+{
+ private readonly HashSet _iecNaturallySortedGrids = new();
+
+ private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not DataGrid grid) return;
+ if (_iecNaturallySortedGrids.Add(grid))
+ {
+ var descriptor = DependencyPropertyDescriptor.FromProperty(
+ ItemsControl.ItemsSourceProperty, typeof(DataGrid));
+ descriptor?.AddValueChanged(grid, (_, _) =>
+ grid.Dispatcher.BeginInvoke(new Action(() => ApplyExplorerNaturalSort(grid))));
+ }
+ ApplyExplorerNaturalSort(grid);
+ }
+
+ private static void ApplyExplorerNaturalSort(DataGrid grid)
+ {
+ if (grid.ItemsSource is null) return;
+ if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view)
+ return;
+ if (ReferenceEquals(view.CustomSort, IecNaturalLiveMonitorSort.Instance))
+ return;
+ view.SortDescriptions.Clear();
+ view.CustomSort = IecNaturalLiveMonitorSort.Instance;
+ }
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index f3acaadf5..2a1a37657 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -662,6 +662,7 @@
string.IsNullOrWhiteSpace(ModelIedName) ? "Not resolved" : ModelIedName;
public string GooseIdDisplay => string.IsNullOrWhiteSpace(GoId) ? "Not provided" : GoId;
+ public string ApplicationIdDecimal
+ {
+ get
+ {
+ var raw = AppIdText.Trim();
+ return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) &&
+ ushort.TryParse(raw.AsSpan(2), NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture, out var value)
+ ? value.ToString(CultureInfo.InvariantCulture) : raw;
+ }
+ }
+ public string SimulationTestText => Test ? "True" : "False";
+ public string DataSetEntryCountText => WireDataSetEntryCount.ToString(CultureInfo.InvariantCulture);
public string DataSetShortName => string.IsNullOrWhiteSpace(DataSetReference) ? "Not provided" : ShortReference(DataSetReference);
public string GoCbRefShortName => string.IsNullOrWhiteSpace(GoCbRef) ? "Not provided" : ShortReference(GoCbRef);
public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}";
@@ -36,6 +49,9 @@ private void RaisePresentationProperties()
Raise(nameof(DisplayName));
Raise(nameof(DisplaySecondary));
Raise(nameof(ModelIedDisplay));
+ Raise(nameof(ApplicationIdDecimal));
+ Raise(nameof(SimulationTestText));
+ Raise(nameof(DataSetEntryCountText));
Raise(nameof(GooseIdDisplay));
Raise(nameof(DataSetShortName));
Raise(nameof(GoCbRefShortName));
@@ -78,6 +94,8 @@ public sealed class GooseEventRow : ObservableObject
public string DestinationMac { get; init; } = "—";
public string DataSetName { get; init; } = "—";
public string RelativeTime { get; init; } = "—";
+ public string IedName { get; init; } = "Unresolved";
+ public string GooseId { get; init; } = "Not provided";
public GooseStreamSnapshot? Snapshot { get; init; }
public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); }
diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs
index 2d3d55ea2..6f2d64b09 100644
--- a/Models/GooseSubscriberModels.cs
+++ b/Models/GooseSubscriberModels.cs
@@ -104,6 +104,9 @@ public sealed partial class GooseStreamRow : ObservableObject
private int _changedValueCount;
private bool _test;
private bool _needsCommissioning;
+ private string _vlanId = "Untagged";
+ private string _vlanPriority = "—";
+ private int _wireDataSetEntryCount;
public string StreamKey { get; init; } = string.Empty;
public ObservableCollection Leaves { get; } = new();
@@ -130,6 +133,9 @@ public sealed partial class GooseStreamRow : ObservableObject
public int ChangedValueCount { get => _changedValueCount; set => Set(ref _changedValueCount, Math.Max(0, value)); }
public bool Test { get => _test; set => Set(ref _test, value); }
public bool NeedsCommissioning { get => _needsCommissioning; set => Set(ref _needsCommissioning, value); }
+ public string VlanId { get => _vlanId; private set => Set(ref _vlanId, value); }
+ public string VlanPriority { get => _vlanPriority; private set => Set(ref _vlanPriority, value); }
+ public int WireDataSetEntryCount { get => _wireDataSetEntryCount; private set => Set(ref _wireDataSetEntryCount, value); }
public string IdentityText => !string.IsNullOrWhiteSpace(GoCbRef) ? GoCbRef : (!string.IsNullOrWhiteSpace(GoId) ? GoId : AppIdText);
public string FlagsText => Test && NeedsCommissioning ? "TEST • ndsCom" : Test ? "TEST" : NeedsCommissioning ? "ndsCom" : "Normal";
@@ -144,6 +150,10 @@ public void Apply(GooseStreamSnapshot snapshot)
SourceMac = snapshot.SourceMac;
DestinationMac = snapshot.DestinationMac;
VlanText = snapshot.VlanText;
+ VlanId = snapshot.VlanId;
+ VlanPriority = snapshot.VlanPriority;
+ WireDataSetEntryCount = snapshot.WireDataSetEntryCount == 0
+ ? snapshot.Leaves.Count : snapshot.WireDataSetEntryCount;
StateNumberText = snapshot.StateNumberText;
SequenceNumberText = snapshot.SequenceNumberText;
SequenceStatus = snapshot.SequenceStatus;
@@ -252,4 +262,7 @@ public sealed record GooseStreamSnapshot(
int ChangedValueCount,
bool Test,
bool NeedsCommissioning,
- IReadOnlyList Leaves);
+ IReadOnlyList Leaves,
+ string VlanId = "Untagged",
+ string VlanPriority = "—",
+ int WireDataSetEntryCount = 0);
diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs
index 51a818f4e..2a5b73c7d 100644
--- a/Services/GooseTypedValueInterpreter.cs
+++ b/Services/GooseTypedValueInterpreter.cs
@@ -113,7 +113,9 @@ public static IReadOnlyList Project(IReadOnlyList();
foreach (var value in wireLeaves)
{
- if (!IsValue(value)) continue;
+ if (!IsValue(value) &&
+ !(IsModelBound(value) && value.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)))
+ continue;
var valueRef = Owner(value.SignalReference, out _);
var exact = qualities.Where(q =>
string.Equals(valueRef, Owner(q.SignalReference, out _), StringComparison.OrdinalIgnoreCase)).ToArray();
@@ -136,7 +138,9 @@ public static IReadOnlyList Project(IReadOnlyList IsValue(row) &&
@@ -144,6 +148,31 @@ public static IReadOnlyList Project(IReadOnlyList
+ q.DataSetIndex == value.DataSetIndex + 1 &&
+ IsModelBound(q) &&
+ q.SignalName.EndsWith(".q", StringComparison.OrdinalIgnoreCase) &&
+ Owner(q.SignalName, out var qKind) == shortOwner &&
+ qKind == "quality" && valueKind == "value" &&
+ IsReadableQuality(q.Value) &&
+ (string.IsNullOrWhiteSpace(q.FunctionalConstraint) ||
+ string.IsNullOrWhiteSpace(value.FunctionalConstraint) ||
+ q.FunctionalConstraint.Equals(value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) &&
+ (CompatibleOwners(valueRef, Owner(q.SignalReference, out _)) ||
+ CompatibleOwners(DoScopedReference(value.SignalReference),
+ DoScopedReference(q.SignalReference)))).ToArray();
+ if (neighbors.Length == 1 &&
+ wireLeaves.Count(row => row.SignalName.Equals(value.SignalName, StringComparison.OrdinalIgnoreCase)) == 1)
+ candidate = neighbors[0];
+ }
if (candidate is null || !pairedQualities.Add(candidate.DataSetIndex))
continue;
pairs[value.DataSetIndex] = candidate;
@@ -166,6 +195,30 @@ public static IReadOnlyList Project(IReadOnlyList= 0 && dot + 4 < key.Length &&
+ key.AsSpan(dot + 1, 3).Equals("ST.".AsSpan(), StringComparison.OrdinalIgnoreCase))
+ key = key.Remove(dot + 1, 3);
+ foreach (var suffix in new[] { ".stVal", ".q" })
+ if (key.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
+ key = key[..^suffix.Length];
+ return key;
+ }
+
+ private static bool IsReadableQuality(string value) =>
+ value.Equals("Good", StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith("Good ·", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("Invalid", StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith("Invalid ·", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("Questionable", StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith("Questionable ·", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("Reserved", StringComparison.OrdinalIgnoreCase);
+
private static bool IsModelBound(GooseLeafValueSnapshot leaf)
=> !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(leaf.SignalReference);
diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml
index f27e2e488..1c5c708b9 100644
--- a/Views/GooseSubscriberLiteView.xaml
+++ b/Views/GooseSubscriberLiteView.xaml
@@ -354,9 +354,10 @@
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
@@ -462,24 +464,33 @@
-
+
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -490,22 +501,39 @@
-
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -516,23 +544,51 @@
-
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs
index 48f38b530..ff032c54d 100644
--- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs
+++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs
@@ -92,6 +92,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string reference,string typ
Assert.Equal(2,projected[1].DataSetIndex);
}
+ [Fact]
+ public void SameSclDoScopedReferencePlusAdjacentTypedQuality_PairsWithoutGuessing()
+ {
+ static GooseLeafValueSnapshot Leaf(int order,string name,string reference,string type,string value)
+ => new(order,order-1,name,reference,"ST","DPC",type,value,"",false,"SCL");
+ var wire=new[]{
+ Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos","Dbpos","Off"),
+ Leaf(2,"CSWI6.Pos.q","BCUGEF650/CSWI6.Pos","Quality","Good"),
+ Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos","Dbpos","Intermediate"),
+ Leaf(4,"CSWI7.Pos.q","BCUGEF650/CSWI7.Pos","Quality","Questionable")
+ };
+ var view=GooseCanonicalLeafProjection.Project(wire);
+ Assert.Equal(2,view.Count);
+ Assert.Equal("Good",view[0].Quality);
+ Assert.Equal("Questionable",view[1].Quality);
+ Assert.Equal(4,wire.Length);
+ }
+
[Fact]
public void DifferentExplicitLogicalDevicesCannotTransferQuality()
{
@@ -106,6 +124,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string path,string type,str
Assert.Equal("—",projected[0].Quality);
}
+ [Fact]
+ public void F650WireDataSet_RendersQInQualityColumn_WithoutReorderingRawMembers()
+ {
+ static GooseLeafValueSnapshot Leaf(int pos,string name,string reference,string type,string value)
+ => new(pos,pos-1,name,reference,"ST","DPC",type,value,"",false,"SCL");
+ var wire = new[] {
+ Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos.stVal","Dbpos","Off"),
+ Leaf(2,"CSWI6.Pos.q","CSWI6.Pos.q","Quality","Good"),
+ Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos.stVal","Dbpos","Intermediate"),
+ Leaf(4,"CSWI7.Pos.q","CSWI7.Pos.q","Quality","Good")
+ };
+ var view = GooseCanonicalLeafProjection.Project(wire);
+ Assert.Equal(4, wire.Length);
+ Assert.Equal(2, view.Count);
+ Assert.All(view, leaf => Assert.Equal("Good",leaf.Quality));
+ Assert.Equal(new[]{0,2},view.Select(leaf=>leaf.DataSetIndex).ToArray());
+ }
+
[Fact]
public void PreviousTypedQualityAndDbpos_AreReadableInChangeSummary()
{
diff --git a/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs
new file mode 100644
index 000000000..9c1eb35e3
--- /dev/null
+++ b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs
@@ -0,0 +1,78 @@
+using System.Globalization;
+using System.Xml.Linq;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class GooseNoviceInspectorRegressionTests
+{
+ [Fact]
+ public void ApplicationId_IsDerivedFromRealWireValue_NotHardcoded()
+ {
+ var row = new GooseStreamRow { AppIdText = "0x002C" };
+ Assert.Equal("44", row.ApplicationIdDecimal);
+ row.AppIdText = "0x0021";
+ Assert.Equal("33", row.ApplicationIdDecimal);
+ }
+
+ [Fact]
+ public void Inspector_UsesRealWireVlanAndDatasetEntryCount()
+ {
+ var row = new GooseStreamRow { StreamKey="G1" };
+ var snapshot = new GooseStreamSnapshot(
+ StreamKey:"G1",AppIdText:"0x002C",GoCbRef:"LD/LLN0$GO$gcb",
+ GoId:"BCUGE03",DataSetReference:"LD/LLN0.GOOSE1",
+ SourceMac:"00:11:22:33:44:55",DestinationMac:"01:0C:CD:01:00:33",
+ VlanText:"VID 0 / PCP 4",StateNumberText:"2",SequenceNumberText:"185",
+ SequenceStatus:"Retransmission",TimeAllowedToLiveText:"2000 ms",
+ ConfigurationRevisionText:"1",ModelIedName:"BCUGE",BindingSource:"SCL",
+ DiagnosticsSummary:"",LastSeenText:"16:00:00.000",PacketCount:14,
+ ChangedValueCount:0,Test:true,NeedsCommissioning:false,
+ Leaves:Array.Empty(),VlanId:"0",VlanPriority:"4",
+ WireDataSetEntryCount:4);
+ row.Apply(snapshot);
+ Assert.Equal("44",row.ApplicationIdDecimal);
+ Assert.Equal("0",row.VlanId);
+ Assert.Equal("4",row.VlanPriority);
+ Assert.Equal("2",row.StateNumberText);
+ Assert.Equal("185",row.SequenceNumberText);
+ Assert.Equal("True",row.SimulationTestText);
+ Assert.Equal("4",row.DataSetEntryCountText);
+ }
+
+ [Fact]
+ public void ActualGooseView_UsesNoviceMetadataLabelsAndTimelineColumns()
+ {
+ var view=Read("Views/GooseSubscriberLiteView.xaml");
+ var xml=XDocument.Parse(view);
+ XNamespace wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ var headers=xml.Descendants(wpf+"DataGridTextColumn")
+ .Select(x=>(string?)x.Attribute("Header")).ToArray();
+ Assert.Contains("IED",headers);
+ Assert.Contains("GOOSE ID",headers);
+ var time=xml.Descendants(wpf+"DataGridTextColumn")
+ .Single(x=>(string?)x.Attribute("Header")=="Time");
+ Assert.True(int.Parse((string)time.Attribute("Width")!,CultureInfo.InvariantCulture)>=110);
+ foreach(var label in new[]{"Application ID","VLAN ID","VLAN priority",
+ "Time allowed to live","Status number","Sequence number",
+ "Configuration revision","Simulation test","Number of DataSet entries"})
+ Assert.Contains($"Text=\"{label}\"",view);
+ Assert.Contains("SelectedGooseStream.ApplicationIdDecimal",view);
+ Assert.Contains("WireDataSetEntryCount",Read("Models/GooseSubscriberModels.cs"));
+ Assert.Contains("rawValueCount);",Read("MainWindow.GooseSubscriber.cs"));
+ Assert.Contains("IedName = ",Read("MainWindow.GooseTimeline.cs"));
+ Assert.Contains("GooseId = ",Read("MainWindow.GooseTimeline.cs"));
+ }
+
+ private static string Read(string path)
+ {
+ DirectoryInfo? d=new(AppContext.BaseDirectory);
+ while(d!=null)
+ {
+ var file=Path.Combine(d.FullName,path);
+ if(File.Exists(file))return File.ReadAllText(file);
+ d=d.Parent;
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs
new file mode 100644
index 000000000..96d10546c
--- /dev/null
+++ b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs
@@ -0,0 +1,39 @@
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class NativeExplorerNaturalSortRegressionTests
+{
+ [Fact]
+ public void RealSevenColumnExplorerGrid_AttachesTheNaturalSortHandler()
+ {
+ var xaml=Read("MainWindow.xaml");
+ Assert.Contains("ItemsSource=\"{Binding SelectedDevice.Points}\" IsReadOnly=\"True\"",xaml);
+ Assert.Contains("Loaded=\"ExplorerLiveGrid_Loaded\"",xaml);
+ var source=Read("MainWindow.IecNaturalSort.cs");
+ Assert.Contains("view.CustomSort = IecNaturalLiveMonitorSort.Instance",source);
+ Assert.Contains("view.SortDescriptions.Clear()",source);
+ }
+
+ [Fact]
+ public void CorrectOrderingIncludesDoubleDigitInstances()
+ {
+ var source=new[]{"F650/CSWI11.Pos.stVal","F650/CSWI2.Pos.stVal",
+ "F650/CSWI1.Pos.stVal","F650/CSWI10.Pos.stVal"};
+ var sorted=source.OrderBy(x=>x,Comparer.Create(IecNaturalReferenceOrder.Compare)).ToArray();
+ Assert.Equal(new[]{"F650/CSWI1.Pos.stVal","F650/CSWI2.Pos.stVal",
+ "F650/CSWI10.Pos.stVal","F650/CSWI11.Pos.stVal"},sorted);
+ }
+
+ private static string Read(string path)
+ {
+ DirectoryInfo? d=new(AppContext.BaseDirectory);
+ while(d!=null)
+ {
+ var f=Path.Combine(d.FullName,path);
+ if(File.Exists(f))return File.ReadAllText(f);
+ d=d.Parent;
+ }
+ throw new FileNotFoundException(path);
+ }
+}