Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ private void FlushGooseSubscriberUi()
}

row.Apply(snapshot);
// In follow-live mode the inspector shows current stream state
// between meaningful events; selecting history freezes the frame.
if (FollowLatestGooseEvents && _selectedGooseEvent?.StreamKey == snapshot.StreamKey)
SelectedGooseStream = row;
processed++;
}

Expand Down
46 changes: 44 additions & 2 deletions MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ public partial class MainWindow
private readonly ConcurrentQueue<(GooseSubscriberFrameSnapshot Frame, GooseTimelineDecision Decision)> _pendingGooseTimeline = new();
private readonly Dictionary<string, GooseTimelineSignature> _lastGooseTimelineSignature = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gooseTimelineGate = new();
private readonly GooseCrossStreamEventPolicy _gooseMirroredEvents = new();
private bool _settingGooseLiveSelection;
private bool _followLatestGooseEvents = true;
public bool FollowLatestGooseEvents
{
get => _followLatestGooseEvents;
set
{
if (!Set(ref _followLatestGooseEvents, value)) return;
if (value && GooseEvents.Count > 0)
{
_settingGooseLiveSelection = true;
try { SelectedGooseEvent = GooseEvents[^1]; }
finally { _settingGooseLiveSelection = false; }
}
}
}
private bool _showGooseRetransmissions;
public bool ShowGooseRetransmissions
{
Expand All @@ -40,6 +57,8 @@ public GooseEventRow? SelectedGooseEvent
{
if (!Set(ref _selectedGooseEvent, value))
return;
if (!_settingGooseLiveSelection && value is not null)
FollowLatestGooseEvents = false;

if (value is not null)
{
Expand Down Expand Up @@ -213,6 +232,18 @@ private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame)
_lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous);
decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions);
_lastGooseTimelineSignature[frame.StreamKey] = fingerprint;
if (decision.Include && decision.PayloadChanged && !decision.IsNew &&
!ShowGooseRetransmissions)
{
var changes = frame.StreamEvent.GooseValues
.Where(v => v.IsChanged && v.SignalReference.EndsWith(".stVal",StringComparison.OrdinalIgnoreCase))
.Select(v => new GooseProcessDelta(v.SignalReference,v.DisplayValue,v.IsMappedToScl))
.ToArray();
var identity = GooseCrossStreamEventPolicy.Identity(
frame.Frame.Source.ToString(),changes);
if (_gooseMirroredEvents.IsMirror(identity,frame.StreamKey,frame.CaptureTimestamp))
return;
}
}
if (!decision.Include) return;
_pendingGooseTimeline.Enqueue((frame, decision));
Expand Down Expand Up @@ -242,7 +273,12 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args)
while (GooseEvents.Count > MaxGooseTimelineEvents)
GooseEvents.RemoveAt(0);

SelectedGooseEvent ??= eventRow;
if (FollowLatestGooseEvents)
{
_settingGooseLiveSelection = true;
try { SelectedGooseEvent = eventRow; }
finally { _settingGooseLiveSelection = false; }
}
processed++;
}

Expand Down Expand Up @@ -401,7 +437,13 @@ private void ResetGooseTimelineUi()
}
_lastGooseTimelineTimestamp.Clear();
_gooseTimelineStart = null;
lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear();
lock (_gooseTimelineGate)
{
_lastGooseTimelineSignature.Clear();
_gooseMirroredEvents.Reset();
}
_followLatestGooseEvents = true;
Raise(nameof(FollowLatestGooseEvents));
_nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue;
GooseEvents.Clear();
SelectedGooseEvent = null;
Expand Down
6 changes: 6 additions & 0 deletions MainWindow.LiveSignalSearch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Windows.Input;
using System.Windows.Media;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

Expand Down Expand Up @@ -191,6 +192,11 @@ private void AttachLiveSignalSearchSource()
_liveSignalSearchView = source == null
? null
: CollectionViewSource.GetDefaultView(source);
// Natural ordering is presentation-only. The underlying static DataSet
// descriptors and MMS/GOOSE FCDA order must remain untouched.
if (_liveSignalSearchView is ListCollectionView listView &&
listView.SortDescriptions.Count == 0 && listView.CustomSort is null)
listView.CustomSort = IecNaturalLiveMonitorSort.Instance;
ApplyLiveSignalSearch();
}

Expand Down
52 changes: 52 additions & 0 deletions Services/GooseCrossStreamEventPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace ArIED61850Tester.Services;

public sealed record GooseProcessDelta(string Reference, string Value, bool IsVerified);

/// <summary>
/// Coalesces only an identical, explicitly identified value transition reflected
/// by a second GOOSE stream from the same station within a short wire-time window.
/// Never affects capture counts, protocol state or source stream histories.
/// </summary>
public sealed class GooseCrossStreamEventPolicy
{
private readonly Dictionary<string,(string Stream, DateTimeOffset Time)> _recent =
new(StringComparer.OrdinalIgnoreCase);

public static string? Identity(string sourceMac, IReadOnlyList<GooseProcessDelta> changes)
{
if (changes.Count != 1 || !changes[0].IsVerified ||
string.IsNullOrWhiteSpace(sourceMac)) return null;
var item = changes[0];
var path = item.Reference.Trim().Replace('$','.');
var slash = path.LastIndexOf('/');
var pos = path.IndexOf(".ST.", slash+1, StringComparison.OrdinalIgnoreCase);
if (pos >= 0) path = path.Remove(pos,3);
if (!path.EndsWith(".stVal",StringComparison.OrdinalIgnoreCase) &&
!path.EndsWith(".mag.f",StringComparison.OrdinalIgnoreCase))
return null;
// Never assume an abbreviated LN name globally identifies a publisher.
if (path.LastIndexOf('/') < 1) return null;
return sourceMac+"|"+path.ToUpperInvariant()+"|"+item.Value;
}

public bool IsMirror(string? identity,string streamKey,DateTimeOffset timestamp)
{
if (identity is null) return false;
if (_recent.TryGetValue(identity,out var seen) &&
!seen.Stream.Equals(streamKey,StringComparison.OrdinalIgnoreCase) &&
timestamp>=seen.Time && timestamp-seen.Time<=TimeSpan.FromMilliseconds(250))
return true;
_recent[identity]=(streamKey,timestamp);
// Work is O(1) per accepted event; bounded occasional cleanup only.
if (_recent.Count>256)
{
foreach(var expired in _recent.Where(pair =>
timestamp-pair.Value.Time>TimeSpan.FromSeconds(1)).Select(pair=>pair.Key).ToArray())
_recent.Remove(expired);
if (_recent.Count>256) _recent.Clear();
}
return false;
}

public void Reset() => _recent.Clear();
}
12 changes: 10 additions & 2 deletions Services/GooseTimelineEventPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, G
current.Sequence.Contains("Normal", StringComparison.OrdinalIgnoreCase);
var newAnomaly = !first && SequenceFamily(previous!.Sequence) != SequenceFamily(current.Sequence) &&
!normal && !string.IsNullOrWhiteSpace(current.Sequence);
return new GooseTimelineDecision(first || valueChanged || stateChanged || diagnosticChanged || newAnomaly || showRetransmissions,
first, valueChanged, stateChanged, diagnosticChanged, newAnomaly);
// Normal Test=true, changing TAL/gap and sequence classifications are
// packet diagnostics, not new process events. Only a newly introduced
// model identity/configuration fault is an independent warning row.
var newCriticalFinding = diagnosticChanged &&
(current.Diagnostics.Contains("confRev", StringComparison.OrdinalIgnoreCase) ||
current.Diagnostics.Contains("APPID differs", StringComparison.OrdinalIgnoreCase) ||
current.Diagnostics.Contains("DataSet reference differs", StringComparison.OrdinalIgnoreCase));
return new GooseTimelineDecision(first || valueChanged || stateChanged ||
newCriticalFinding || showRetransmissions,
first, valueChanged, stateChanged, newCriticalFinding, newAnomaly);
}
}
94 changes: 73 additions & 21 deletions Services/GooseTypedValueInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,50 +97,102 @@ public static string RenderQuality(MmsDataValue value)
}

/// <summary>
/// Exact reference-identity engineering view of GOOSE allData. Raw wire
/// entries remain separately available and untouched, including orphan q.
/// Engineering readout only: preserves every original ordered GOOSE FCDA in
/// GooseStreamRow.Leaves. Pairs q only where its owner is unambiguous.
/// </summary>
public static class GooseCanonicalLeafProjection
{
public static IReadOnlyList<GooseLeafValueSnapshot> Project(IReadOnlyList<GooseLeafValueSnapshot> wireLeaves)
{
var owners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var quality = new Dictionary<string, List<GooseLeafValueSnapshot>>(StringComparer.OrdinalIgnoreCase);
foreach (var leaf in wireLeaves)
var values = wireLeaves.Where(leaf =>
Owner(leaf.SignalReference, out var kind) is not null && kind == "value").ToArray();
var qualities = wireLeaves.Where(leaf =>
Owner(leaf.SignalReference, out var kind) is not null && kind == "quality").ToArray();

var pairs = new Dictionary<int, GooseLeafValueSnapshot>();
var pairedQualities = new HashSet<int>();
foreach (var value in wireLeaves)
{
var key = CanonicalOwner(leaf.SignalReference, out var kind);
if (key is null) continue;
if (kind == "value") owners.Add(key);
else if (kind == "quality")
if (!IsValue(value)) continue;
var valueRef = Owner(value.SignalReference, out _);
var exact = qualities.Where(q =>
string.Equals(valueRef, Owner(q.SignalReference, out _), StringComparison.OrdinalIgnoreCase)).ToArray();
// Exact qualified-reference pairing has priority. Never accept
// duplicate status/quality owners as a unique match.
var exactValueCount = values.Count(other =>
string.Equals(valueRef, Owner(other.SignalReference, out _), StringComparison.OrdinalIgnoreCase));
GooseLeafValueSnapshot? candidate = exact.Length == 1 && exactValueCount == 1
? exact[0] : null;

if (candidate is null && exact.Length == 0 && IsModelBound(value))
{
if (!quality.TryGetValue(key, out var found))
quality[key] = found = new List<GooseLeafValueSnapshot>(1);
found.Add(leaf);
// Some SCL publishers expose mixed abbreviated DA references.
// The ordered wire DataSet itself provides a stronger fallback:
// adjacent typed q following stVal, same FC, exact LN.DO name,
// unique in this stream. Never match unrelated LD-qualified refs.
var ownerName = Owner(value.SignalName, out var valueKind);
var adjacent = wireLeaves.Where(q => q.DataSetIndex == value.DataSetIndex + 1 &&
IsModelBound(q) && IsQuality(q) &&
q.FunctionalConstraint.Equals(value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase) &&
string.Equals(ownerName, Owner(q.SignalName, out var qKind), StringComparison.OrdinalIgnoreCase) &&
valueKind == "value" && qKind == "quality" &&
CompatibleOwners(valueRef, Owner(q.SignalReference, out _))).ToArray();
if (adjacent.Length == 1)
{
var occurrences = wireLeaves.Count(row => IsValue(row) &&
string.Equals(ownerName, Owner(row.SignalName, out _), StringComparison.OrdinalIgnoreCase));
if (occurrences == 1) candidate = adjacent[0];
}
}
if (candidate is null || !pairedQualities.Add(candidate.DataSetIndex))
continue;
pairs[value.DataSetIndex] = candidate;
}

var result = new List<GooseLeafValueSnapshot>(wireLeaves.Count);
foreach (var leaf in wireLeaves)
{
var key = CanonicalOwner(leaf.SignalReference, out var kind);
quality.TryGetValue(key ?? string.Empty, out var companions);
var hasUniqueCompanion = key is not null && owners.Contains(key) &&
companions is { Count: 1 };
if (hasUniqueCompanion && kind == "quality") continue;
if (hasUniqueCompanion && kind == "value")
if (pairedQualities.Contains(leaf.DataSetIndex)) continue;
if (pairs.TryGetValue(leaf.DataSetIndex, out var quality))
result.Add(leaf with
{
SignalName = leaf.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)
? leaf.SignalName[..^6] : leaf.SignalName,
Quality = companions![0].Value,
IsChanged = leaf.IsChanged || companions[0].IsChanged
Quality = quality.Value,
IsChanged = leaf.IsChanged || quality.IsChanged
});
else result.Add(leaf);
}
return result;
}

private static string? CanonicalOwner(string? source, out string kind)
private static bool IsModelBound(GooseLeafValueSnapshot leaf)
=> !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(leaf.SignalReference);

private static bool IsValue(GooseLeafValueSnapshot leaf)
=> Owner(leaf.SignalReference, out var kind) is not null && kind == "value";

private static bool IsQuality(GooseLeafValueSnapshot leaf)
=> Owner(leaf.SignalReference, out var kind) is not null && kind == "quality" &&
(GooseTypedValueInterpreter.IsQuality(leaf.SignalReference, leaf.BType) ||
leaf.SignalName.EndsWith(".q", StringComparison.OrdinalIgnoreCase));

private static bool CompatibleOwners(string? primary, string? quality)
{
if (primary is null || quality is null) return false;
if (primary.Equals(quality, StringComparison.OrdinalIgnoreCase)) return true;
// An abbreviated SCL reference may omit its LD prefix. Two explicit
// different LD prefixes are NOT equivalent, regardless of matching LN.
var a = primary.LastIndexOf('/');
var b = quality.LastIndexOf('/');
if (a >= 0 && b >= 0) return false;
return (a >= 0 ? primary[(a+1)..] : primary)
.Equals(b >= 0 ? quality[(b+1)..] : quality,
StringComparison.OrdinalIgnoreCase);
}

private static string? Owner(string? source, out string kind)
{
kind = string.Empty;
if (string.IsNullOrWhiteSpace(source)) return null;
Expand Down
59 changes: 59 additions & 0 deletions Services/IecNaturalReferenceOrder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>Numeric-aware comparison of IEC telegram text for operator grids only.</summary>
public static class IecNaturalReferenceOrder
{
public static int Compare(string? left, string? right)
{
left ??= string.Empty;
right ??= string.Empty;
var i = 0;
var j = 0;
while (i < left.Length && j < right.Length)
{
if (char.IsAsciiDigit(left[i]) && char.IsAsciiDigit(right[j]))
{
var startI = i;
var startJ = j;
while (i < left.Length && left[i] == '0') i++;
while (j < right.Length && right[j] == '0') j++;
var digitsI = i;
var digitsJ = j;
while (i < left.Length && char.IsAsciiDigit(left[i])) i++;
while (j < right.Length && char.IsAsciiDigit(right[j])) j++;
var numericI = i - digitsI;
var numericJ = j - digitsJ;
if (numericI != numericJ) return numericI.CompareTo(numericJ);
var byDigits = string.Compare(left, digitsI, right, digitsJ, numericI, StringComparison.Ordinal);
if (byDigits != 0) return byDigits;
// Same integer: fewer leading zeros first, deterministic and stable.
if (i - startI != j - startJ) return (i - startI).CompareTo(j - startJ);
continue;
}
var a = char.ToUpperInvariant(left[i++]);
var b = char.ToUpperInvariant(right[j++]);
if (a != b) return a.CompareTo(b);
}
return left.Length == i && right.Length == j ? 0 : left.Length == i ? -1 : 1;
}
}

public sealed class IecNaturalLiveMonitorSort : IComparer
{
public static IecNaturalLiveMonitorSort Instance { get; } = new();

public int Compare(object? a, object? b)
{
if (ReferenceEquals(a,b)) return 0;
if (a is not Iec61850MonitorPoint first || b is not Iec61850MonitorPoint second)
return Comparer.DefaultInvariant.Compare(a,b);
var result = IecNaturalReferenceOrder.Compare(first.IecTelegram,second.IecTelegram);
if (result != 0) return result;
result = IecNaturalReferenceOrder.Compare(first.DeviceName,second.DeviceName);
return result != 0 ? result :
IecNaturalReferenceOrder.Compare(first.IecReference,second.IecReference);
}
}
17 changes: 12 additions & 5 deletions Views/GooseSubscriberLiteView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,18 @@
FontWeight="SemiBold" Foreground="#45648E"/>
</Border>
</StackPanel>
<CheckBox Grid.Column="1" Content="Show retransmissions"
IsChecked="{Binding ShowGooseRetransmissions, Mode=TwoWay}"
FontSize="9.6" Foreground="{StaticResource Muted}"
VerticalAlignment="Center"
ToolTip="Show all GOOSE retransmissions; changes and new warnings appear by default."/>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox Content="Follow latest" Margin="0,0,14,0"
IsChecked="{Binding FollowLatestGooseEvents, Mode=TwoWay}"
FontSize="10" Foreground="{StaticResource Muted}"
VerticalAlignment="Center"
ToolTip="Follow the newest change; deselect to inspect an immutable historical frame."/>
<CheckBox Content="Show retransmissions"
IsChecked="{Binding ShowGooseRetransmissions, Mode=TwoWay}"
FontSize="10" Foreground="{StaticResource Muted}"
VerticalAlignment="Center"
ToolTip="Display every received frame in Messages, including retransmissions. Raw capture remains available when off."/>
</StackPanel>
</Grid>

<Grid Grid.Row="2">
Expand Down
Loading
Loading