diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 3a3312e91..ccab206d3 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -16,6 +16,8 @@ namespace ArIED61850Tester; public partial class MainWindow { private readonly GooseSubscriberRuntime _gooseSubscriberRuntime = new(); + private const int MaxGooseCachedStreams = 256; + private readonly object _gooseCacheGate = new(); private readonly ConcurrentDictionary _pendingGooseFrames = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _latestGooseFrames = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _gooseStreamIndex = new(StringComparer.OrdinalIgnoreCase); @@ -217,8 +219,11 @@ private void ClearGooseSubscriber_Click(object sender, RoutedEventArgs e) private void ResetGooseView(bool resetCounters) { - _pendingGooseFrames.Clear(); - _latestGooseFrames.Clear(); + lock (_gooseCacheGate) + { + _pendingGooseFrames.Clear(); + _latestGooseFrames.Clear(); + } _gooseStreamIndex.Clear(); GooseStreams.Clear(); SelectedGooseStream = null; @@ -364,8 +369,33 @@ private static string DescribeGooseFailure(Exception exception) private void GooseSubscriberRuntime_FrameReceived(GooseSubscriberFrameSnapshot snapshot) { - _latestGooseFrames[snapshot.StreamKey] = snapshot; - _pendingGooseFrames[snapshot.StreamKey] = snapshot; + // Canonical cache: one latest typed frame per stream. Retransmissions + // replace previous state rather than allocating a new GUI object. + // Eviction occurs only for a NEW stream beyond budget; normal capture + // therefore takes O(1) dictionary operations without sorting. + lock (_gooseCacheGate) + { + if (!_latestGooseFrames.ContainsKey(snapshot.StreamKey) && + _latestGooseFrames.Count >= MaxGooseCachedStreams) + { + KeyValuePair? oldest = null; + foreach (var entry in _latestGooseFrames) + { + if (!oldest.HasValue || + entry.Value.CaptureTimestamp < oldest.Value.Value.CaptureTimestamp) + oldest = entry; + } + if (oldest.HasValue) + { + _latestGooseFrames.TryRemove(oldest.Value.Key, out _); + _pendingGooseFrames.TryRemove(oldest.Value.Key, out _); + lock (_gooseTimelineGate) + _lastGooseTimelineSignature.Remove(oldest.Value.Key); + } + } + _latestGooseFrames[snapshot.StreamKey] = snapshot; + _pendingGooseFrames[snapshot.StreamKey] = snapshot; + } } private void GooseSubscriberRuntime_StatusChanged(GooseSubscriberStatusSnapshot status) @@ -698,8 +728,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( var bindingSource = decoded?.IsMappedToScl == true ? "SCL" : binding?.Source ?? "Unbound"; + var resolvedCdc = decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty; + var resolvedType = decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty; var value = index < rawValueCount - ? decoded?.DisplayValue ?? MmsDataValueRenderer.ToCompactString(frame.Pdu.Values[index], signalReference) + ? GooseTypedValueInterpreter.Render(frame.Pdu.Values[index], resolvedCdc, resolvedType, signalReference) : ""; leaves.Add(new GooseLeafValueSnapshot( @@ -708,8 +740,8 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( definition?.SignalName ?? BuildSignalName(signalReference, index), signalReference, decoded?.IsMappedToScl == true ? decoded.Fc : definition?.FunctionalConstraint ?? string.Empty, - decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty, - decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty, + resolvedCdc, + resolvedType, value, decoded?.PreviousDisplayValue ?? string.Empty, decoded?.IsChanged ?? false, @@ -770,7 +802,7 @@ private static string FirstNonEmpty(params string?[] values) private static string BuildSignalName(string reference, int index) { if (string.IsNullOrWhiteSpace(reference)) - return $"Leaf {index + 1}"; + return $"Unmapped value #{index + 1}"; var clean = Regex.Replace(reference, @"\[[^\]]+\]$", string.Empty); var slash = clean.LastIndexOf('/'); @@ -831,32 +863,51 @@ public GooseBindingCatalog(SclDocument? sclDocument, IReadOnlyList 0 && !string.Equals(goCb, frameGoCb, StringComparison.OrdinalIgnoreCase)) + continue; + if (dataSet.Length > 0 && !string.Equals(dataSet, frameDataSet, StringComparison.OrdinalIgnoreCase)) + continue; + + var goCbExact = goCb.Length > 0; + var dataSetExact = dataSet.Length > 0; + if (!goCbExact && !dataSetExact) continue; + var score = (goCbExact ? 120 : 0) + (dataSetExact ? 90 : 0) + + (binding.AppId.HasValue ? 50 : 0) + + (binding.ConfigurationRevision.HasValue ? 10 : 0) + + (binding.Source.Equals("SCL", StringComparison.OrdinalIgnoreCase) ? 5 : 0); if (score > bestScore) { + winner = binding; bestScore = score; - best = candidate; + ambiguous = false; } + else if (score == bestScore) + ambiguous = true; } - return bestScore >= 90 ? best : null; + return ambiguous ? null : winner; } } } diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 0ed13c6e4..9cde47ad7 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -25,6 +25,7 @@ public bool ShowGooseRetransmissions get => _showGooseRetransmissions; set => Set(ref _showGooseRetransmissions, value); } + private DateTimeOffset? _gooseTimelineStart; private readonly Dictionary _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase); private GooseEventRow? _selectedGooseEvent; private bool _goosePresentationInstalled; @@ -40,14 +41,29 @@ public GooseEventRow? SelectedGooseEvent if (!Set(ref _selectedGooseEvent, value)) return; - if (value is not null && _gooseStreamIndex.TryGetValue(value.StreamKey, out var stream)) - SelectedGooseStream = stream; + if (value is not null) + { + // Inspector is immutable for the chosen historical frame. + // Streaming latest publisher state must never mutate this row. + if (value.Snapshot is { } snapshot) + { + var historical = new GooseStreamRow { StreamKey = value.StreamKey }; + historical.Apply(snapshot); + SelectedGooseStream = historical; + } + else if (_gooseStreamIndex.TryGetValue(value.StreamKey, out var simulated)) + SelectedGooseStream = simulated; + } + else + SelectedGooseStream = null; } } public string GoosePublisherCountText => $"{GooseStreams.Count:N0}"; public string GooseEventCountText => $"{GooseEvents.Count:N0}"; - public string GooseSelectedLeafCountText => $"{SelectedGooseStream?.Leaves.Count ?? 0:N0} values"; + public string GooseSelectedLeafCountText => SelectedGooseStream is null + ? "0 signals" + : $"{SelectedGooseStream.EngineeringLeaves.Count:N0} signals · {SelectedGooseStream.Leaves.Count:N0} entries"; public Visibility GooseNoEventsVisibility => GooseEvents.Count == 0 ? Visibility.Visible : Visibility.Collapsed; [ModuleInitializer] @@ -69,6 +85,10 @@ internal static void RegisterGoosePresentationIntegration() typeof(MainWindow), GooseSubscriberLiteView.AdapterConfirmedEvent, new RoutedEventHandler(OnGooseAdapterConfirmed)); + EventManager.RegisterClassHandler( + typeof(MainWindow), + GooseSubscriberLiteView.ImportPublisherModelRequestedEvent, + new RoutedEventHandler(OnImportGoosePublisherModelRequested)); EventManager.RegisterClassHandler( typeof(MainWindow), GooseSubscriberLiteView.StartRequestedEvent, @@ -138,6 +158,16 @@ private static void OnGooseAdapterConfirmed(object sender, RoutedEventArgs args) args.Handled = true; } + private static void OnImportGoosePublisherModelRequested(object sender, RoutedEventArgs args) + { + if (sender is not MainWindow window) + return; + // Uses the existing, engine-validated SCL import workflow. Never infer + // publisher IP from GOOSE source MAC or synthesize missing FCDA identities. + window.OpenScl_Click(window, new RoutedEventArgs()); + args.Handled = true; + } + private static void OnStartGooseRequested(object sender, RoutedEventArgs args) { if (sender is not MainWindow window) @@ -179,6 +209,7 @@ private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame) GooseTimelineDecision decision; lock (_gooseTimelineGate) { + _gooseTimelineStart ??= frame.CaptureTimestamp; _lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous); decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions); _lastGooseTimelineSignature[frame.StreamKey] = fingerprint; @@ -238,15 +269,17 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, var valueAnomaly = decision.PayloadChanged && !decision.StateChanged; var changed = decision.PayloadChanged || decision.StateChanged; var eventText = decision.IsNew ? "New" : - valueAnomaly ? "Value changed" : + valueAnomaly ? "Unexpected change" : + changed ? "Changed" : hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : - changed ? "State change" : FriendlySequenceStatus(stream.SequenceStatus); + FriendlySequenceStatus(stream.SequenceStatus); var tone = valueAnomaly || hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : changed ? "Change" : "Info"; var deltaText = "-"; if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var prev)) deltaText = FormatGooseDelta(captured.CaptureTimestamp - prev); _lastGooseTimelineTimestamp[captured.StreamKey] = captured.CaptureTimestamp; + _gooseTimelineStart ??= captured.CaptureTimestamp; return new GooseEventRow { StreamKey = captured.StreamKey, @@ -256,8 +289,14 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, EventTone = tone, Publisher = BuildGoosePublisherName(stream), StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", - Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." : - BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) + SourceMac = stream.SourceMac, + DestinationMac = stream.DestinationMac, + DataSetName = ShortGooseReference(stream.DataSetReference), + RelativeTime = (captured.CaptureTimestamp - _gooseTimelineStart.Value).TotalSeconds.ToString("0.000000", CultureInfo.InvariantCulture), + Snapshot = stream, + Summary = valueAnomaly + ? "Unexpected stNum: " + BuildGooseEventSummary(stream, false, hasNewWarning) + : BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) }; } @@ -274,18 +313,21 @@ private static string BuildGoosePublisherName(GooseStreamSnapshot stream) private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool isNew, bool hasDiagnostics) { - if (hasDiagnostics) - return ShortenGooseText(stream.DiagnosticsSummary, 150); - + // The details column must lead with actual signal deltas, not a + // persistent Test=true banner or a raw sequence diagnostic. var changed = stream.Leaves .Where(leaf => leaf.IsChanged) .Take(2) .Select(leaf => { - var current = ShortenGooseText(GooseEngineeringValueFormatter.Format(leaf.Value), 30); - return IsGenericGooseLeafName(leaf.SignalName) - ? current - : $"{ShortenGooseText(leaf.SignalName, 22)}: {current}"; + var name = IsGenericGooseLeafName(leaf.SignalName) + ? $"Value {leaf.Order}" + : ShortenGooseText(leaf.SignalName, 22); + var before = GooseTypedValueInterpreter.RenderPrevious(leaf.PreviousValue, leaf.Cdc, leaf.BType); + var after = ShortenGooseText(leaf.Value, 26); + return string.IsNullOrWhiteSpace(before) + ? $"{name}: {after}" + : $"{name}: {ShortenGooseText(before, 20)} → {after}"; }) .ToArray(); if (changed.Length > 0) @@ -297,8 +339,9 @@ private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool is } if (isNew) - return $"Publisher detected • {stream.Leaves.Count:N0} DataSet value(s)"; - + return $"Publisher detected · {stream.Leaves.Count:N0} DataSet entries"; + if (hasDiagnostics) + return ShortenGooseText(stream.DiagnosticsSummary, 150); return FriendlySequenceStatus(stream.SequenceStatus); } @@ -357,6 +400,7 @@ private void ResetGooseTimelineUi() { } _lastGooseTimelineTimestamp.Clear(); + _gooseTimelineStart = null; lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear(); _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index 0b8fc1c7b..38a793eb2 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -124,13 +124,23 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) if (GooseAdapters.Count == 0) RefreshGooseAdapters(); - // Never assume the unicast MMS route is the GOOSE L2 network. The - // Npcap adapter must be explicitly selected before capture begins. + // MMS route is an initial NIC recommendation, not proof of the + // station GOOSE VLAN. Only auto-start when one non-loopback Npcap + // adapter uniquely matches the actual Windows-routed interface. + var proposed = ResolveGooseAdapterForIed(device, out var route); + if (proposed is not null && !LooksLikeLoopback(proposed)) + { + SelectedGooseAdapter = proposed; + GooseStatusText = $"Listening on suggested adapter {proposed.DisplayText}. Change adapter if GOOSE uses a separate port/VLAN."; + SetStatus($"GOOSE: starting on {proposed.DisplayText}."); + StartGooseSubscriber_Click(this, new RoutedEventArgs()); + return; + } SelectedGooseAdapter = null; _pendingIedGooseAutoStart = device; GooseStatusText = GooseAdapters.Count == 0 - ? "No Ethernet capture adapter found. Check Npcap and refresh the adapter list." - : $"Select the station network adapter for {device.Name}. GOOSE capture starts automatically after selection."; + ? "No Ethernet capture adapter found. Check Npcap and refresh adapters." + : $"Choose the GOOSE station adapter for {device.Name}; capture begins on selection. {route}"; SetStatus($"GOOSE: select network adapter for {device.Name}."); })); } diff --git a/Models/GoosePresentationModels.cs b/Models/GoosePresentationModels.cs index 5e9d05607..49803e66a 100644 --- a/Models/GoosePresentationModels.cs +++ b/Models/GoosePresentationModels.cs @@ -28,7 +28,7 @@ public string DisplaySecondary public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}"; public string StateSequenceCompactText => $"st {StateNumberText} • sq {SequenceNumberText}"; public string ModelStateText => BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) - ? "Raw / unbound" + ? "Signal names unavailable · open matching publisher SCL/CID" : BindingSource; private void RaisePresentationProperties() @@ -72,6 +72,13 @@ public sealed class GooseEventRow : ObservableObject public required string Publisher { get; init; } public required string StateSequenceText { get; init; } public required string Summary { get; init; } + // Real wire events populate all six-column fields plus an immutable frame. + // Simulated demo events may omit the frame; they keep their explicit values. + public string SourceMac { get; init; } = "—"; + public string DestinationMac { get; init; } = "—"; + public string DataSetName { get; init; } = "—"; + public string RelativeTime { get; init; } = "—"; + public GooseStreamSnapshot? Snapshot { get; init; } public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); } public string TimeText => Timestamp.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index 54e670a0f..2d3d55ea2 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -30,6 +30,7 @@ public sealed class GooseLeafValueRow : ObservableObject private string _value = "-"; private string _previousValue = string.Empty; private string _bindingSource = "Unbound"; + private string _quality = "—"; private bool _isChanged; private bool _isHighlighted; private DateTimeOffset _highlightUntilUtc; @@ -44,6 +45,7 @@ public sealed class GooseLeafValueRow : ObservableObject public string Value { get => _value; set => Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value); } public string PreviousValue { get => _previousValue; set => Set(ref _previousValue, value ?? string.Empty); } public string BindingSource { get => _bindingSource; set => Set(ref _bindingSource, string.IsNullOrWhiteSpace(value) ? "Unbound" : value); } + public string Quality { get => _quality; set => Set(ref _quality, value ?? "—"); } public bool IsChanged { get => _isChanged; set => Set(ref _isChanged, value); } public bool IsHighlighted { get => _isHighlighted; private set => Set(ref _isHighlighted, value); } public string TypeText => string.Join(" / ", new[] { Cdc, BType }.Where(item => !string.IsNullOrWhiteSpace(item))); @@ -61,6 +63,7 @@ public void Apply(GooseLeafValueSnapshot snapshot) PreviousValue = snapshot.PreviousValue; Value = snapshot.Value; BindingSource = snapshot.BindingSource; + Quality = snapshot.Quality; IsChanged = snapshot.IsChanged || changedOnWire; if (IsChanged) { @@ -104,6 +107,7 @@ public sealed partial class GooseStreamRow : ObservableObject public string StreamKey { get; init; } = string.Empty; public ObservableCollection Leaves { get; } = new(); + public ObservableCollection EngineeringLeaves { get; } = new(); public string AppIdText { get => _appIdText; set => Set(ref _appIdText, value ?? "-"); } public string GoCbRef { get => _goCbRef; set => Set(ref _goCbRef, value ?? string.Empty); } @@ -154,12 +158,25 @@ public void Apply(GooseStreamSnapshot snapshot) Test = snapshot.Test; NeedsCommissioning = snapshot.NeedsCommissioning; ApplyLeaves(snapshot.Leaves); + ApplyEngineeringLeaves(ArIED61850Tester.Services.GooseCanonicalLeafProjection.Project(snapshot.Leaves)); Raise(nameof(IdentityText)); Raise(nameof(FlagsText)); Raise(nameof(HealthText)); RaisePresentationProperties(); } + private void ApplyEngineeringLeaves(IReadOnlyList snapshots) + { + while (EngineeringLeaves.Count > snapshots.Count) + EngineeringLeaves.RemoveAt(EngineeringLeaves.Count - 1); + for (var index = 0; index < snapshots.Count; index++) + { + if (index >= EngineeringLeaves.Count) + EngineeringLeaves.Add(new GooseLeafValueRow { Order = snapshots[index].Order, DataSetIndex = snapshots[index].DataSetIndex }); + EngineeringLeaves[index].Apply(snapshots[index]); + } + } + private void ApplyLeaves(IReadOnlyList snapshots) { while (Leaves.Count > snapshots.Count) @@ -210,7 +227,8 @@ public sealed record GooseLeafValueSnapshot( string Value, string PreviousValue, bool IsChanged, - string BindingSource); + string BindingSource, + string Quality = "—"); public sealed record GooseStreamSnapshot( string StreamKey, diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs new file mode 100644 index 000000000..3275e9e7c --- /dev/null +++ b/Services/GooseTypedValueInterpreter.cs @@ -0,0 +1,138 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Interprets MMS GOOSE payloads using their actual wire types and verified +/// FCDA metadata. Never assumes an arbitrary bit string is an IEC quality. +/// The raw value remains accessible separately for diagnostics. +/// +public static class GooseTypedValueInterpreter +{ + public static string Render(MmsDataValue value, string? cdc, string? bType, string? reference) + { + if (value.Kind != MmsDataKind.BitString) + return MmsDataValueRenderer.ToCompactString(value, reference); + var raw = value.RawValue; + if (raw.Count == 2 && raw[0] == 6 && + (string.Equals(bType, "Dbpos", StringComparison.OrdinalIgnoreCase) || + string.Equals(cdc, "DPC", StringComparison.OrdinalIgnoreCase))) + return ((raw[1] >> 6) & 3) switch + { + 0 => "Intermediate", + 1 => "Off", + 2 => "On", + _ => "Invalid" + }; + if (IsQuality(reference, bType)) + return RenderQuality(value); + return MmsDataValueRenderer.ToCompactString(value, reference); + } + + /// Legacy previous values have only MMS renderer text, not the old typed node. + public static string RenderPrevious(string? previous, string? cdc, string? bType) + { + if (string.IsNullOrWhiteSpace(previous)) + return string.Empty; + if (string.Equals(bType, "Quality", StringComparison.OrdinalIgnoreCase) && + previous.StartsWith("bits(", StringComparison.OrdinalIgnoreCase) && + previous.Contains("unused=3", StringComparison.OrdinalIgnoreCase)) + { + var data = previous.AsSpan(5); + var comma = data.IndexOf(','); + if (comma == 4 && ushort.TryParse(data[..comma], + System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var bits)) + return RenderQuality(MmsDataValue.BitString(3, new byte[] { (byte)(bits >> 8), (byte)bits })); + } + if ((string.Equals(cdc, "DPC", StringComparison.OrdinalIgnoreCase) || + string.Equals(bType, "Dbpos", StringComparison.OrdinalIgnoreCase)) && + previous.StartsWith("bits(", StringComparison.OrdinalIgnoreCase)) + { + var content = previous.AsSpan(5); + var comma = content.IndexOf(','); + if (comma == 2 && byte.TryParse(content[..comma], System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var bits) && + previous.Contains("unused=6", StringComparison.OrdinalIgnoreCase)) + return ((bits >> 6) & 3) switch + { + 0 => "Intermediate", 1 => "Off", 2 => "On", _ => "Invalid" + }; + } + return GooseEngineeringValueFormatter.Format(previous); + } + + public static bool IsQuality(string? reference, string? bType) + => string.Equals(bType, "Quality", StringComparison.OrdinalIgnoreCase) || + (reference?.EndsWith(".q", StringComparison.OrdinalIgnoreCase) ?? false); + + public static string RenderQuality(MmsDataValue value) + { + if (value.Kind != MmsDataKind.BitString) + return MmsDataValueRenderer.ToCompactString(value); + var raw = value.RawValue; + // IEC 61850 Quality: exactly 13 significant bits, encoded MSB-first. + if (raw.Count != 3 || raw[0] != 3) + return MmsDataValueRenderer.ToCompactString(value); + var flags = (raw[1] << 8) | raw[2]; + var validity = (flags >> 14) & 3; + var pieces = new List(12) + { + validity switch { 0 => "Good", 1 => "Invalid", 2 => "Reserved", _ => "Questionable" } + }; + if ((flags & 0x2000) != 0) pieces.Add("Overflow"); + if ((flags & 0x1000) != 0) pieces.Add("Out of range"); + if ((flags & 0x0800) != 0) pieces.Add("Bad reference"); + if ((flags & 0x0400) != 0) pieces.Add("Oscillatory"); + if ((flags & 0x0200) != 0) pieces.Add("Failure"); + if ((flags & 0x0100) != 0) pieces.Add("Old data"); + if ((flags & 0x0080) != 0) pieces.Add("Inconsistent"); + if ((flags & 0x0040) != 0) pieces.Add("Inaccurate"); + if ((flags & 0x0020) != 0) pieces.Add("Substituted"); + if ((flags & 0x0010) != 0) pieces.Add("Test"); + if ((flags & 0x0008) != 0) pieces.Add("Operator blocked"); + return string.Join(" · ", pieces); + } +} + +/// Pairs only exact, independently identified q and primary FCDA references. +public static class GooseCanonicalLeafProjection +{ + public static IReadOnlyList Project(IReadOnlyList wireLeaves) + { + var quality = new Dictionary(StringComparer.OrdinalIgnoreCase); + var valueOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var leaf in wireLeaves) + { + var path = leaf.SignalReference; + if (string.IsNullOrWhiteSpace(path)) continue; + if (path.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + quality[path[..^2]] = leaf.Value; + else if (path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) + { + var owner = path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) + ? path[..^6] : path[..^6]; + valueOwners.Add(owner); + } + } + var result = new List(wireLeaves.Count); + foreach (var leaf in wireLeaves) + { + var reference = leaf.SignalReference; + if (!string.IsNullOrWhiteSpace(reference) && + reference.EndsWith(".q", StringComparison.OrdinalIgnoreCase) && + valueOwners.Contains(reference[..^2])) + continue; + var objectRef = reference.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) + ? reference[..^6] : reference.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) + ? reference[..^6] : string.Empty; + result.Add(leaf with + { + Quality = objectRef.Length > 0 && quality.TryGetValue(objectRef, out var q) ? q : "—" + }); + } + return result; + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index f3f5baa2a..b0892f12c 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -348,8 +348,8 @@ - - + + - - + + + + @@ -408,7 +410,7 @@ - @@ -547,56 +549,79 @@ + + + + + +