From 9232e375782bc49f3a1056c9b917ed9bf42a49d1 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:12:09 +0700 Subject: [PATCH 01/11] P4B: add ArSubsv field mode and support-bundle workflow --- docs/arsubsv-field-mode-p4.md | 45 +++ engines/ARIEC61850.lock.json | 10 +- src/ARSVIN.Subscriber/Models/PcapFrames.cs | 58 +-- .../ViewModels/SvStreamViewModel.FieldMode.cs | 183 ++++++++++ .../ViewModels/SvSubscriberViewModel.cs | 337 +++++++++--------- .../SampledValues/FieldModeViewModelTests.cs | 63 ++++ 6 files changed, 480 insertions(+), 216 deletions(-) create mode 100644 docs/arsubsv-field-mode-p4.md create mode 100644 src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs create mode 100644 tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs diff --git a/docs/arsubsv-field-mode-p4.md b/docs/arsubsv-field-mode-p4.md new file mode 100644 index 0000000..17ae6a9 --- /dev/null +++ b/docs/arsubsv-field-mode-p4.md @@ -0,0 +1,45 @@ +# ArSubsv Field Mode P4 + +Field Mode is the practical receiver workflow for live IEC 61850 Sampled Values and offline PCAP/PCAPNG analysis. + +## Five evidence axes + +ArSubsv separates: + +- `CAPTURE`: frames available to the application; +- `PROTOCOL`: Ethernet/SV APDU decoding; +- `STREAM`: sample-counter continuity and payload consistency; +- `CONFIGURATION`: observed traffic versus bound CID/SCD; +- `MEASUREMENT`: channel semantics, scaling, CT/VT context, and signal confidence. + +A configuration mismatch does not make a clean protocol stream BAD. Unknown measurement semantics remain UNKNOWN rather than being presented as amperes or volts. + +## Offline workflow + +1. Open `.pcap` or `.pcapng`. +2. Select a discovered stream. +3. Review raw decoded values and continuity without SCL. +4. Import `.cid`, `.scd`, `.icd`, or `.iid` when available. +5. Review scored SCL binding and expected-versus-observed findings. +6. Enter explicit CT/VT context only from reviewed evidence. +7. Export a support bundle for reproducible review. + +## Support bundle + +The initial P4 bundle is metadata-only by default and contains: + +- manifest and SHA-256 checksums; +- receiver evidence in Markdown and JSON; +- five-axis field summary; +- selected-stream diagnostics; +- measurement context when configured; +- application and engine revision evidence; +- SCL hash when an SCL file is loaded. + +The full capture and original SCL are not copied silently. Capture-excerpt and privacy-selection UI are planned as the next tranche. + +## Current limitations + +- Known-injection comparison exists in ARIEC61850 but the ArSubsv entry dialog is not yet implemented. +- Quiet/noise-floor classification is evidence-based and non-destructive, but waveform minimum-axis presentation still needs local UI review. +- Real Ahmed Mohaisn PCAPNG/CID replay remains a local acceptance gate; deterministic tests do not replace real-device evidence. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 603822e..c1b8d4c 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -1,9 +1,9 @@ { "schemaVersion": 1, "repository": "masarray/ARIEC61850", - "ref": "main", - "commit": "0f8453182957900bc6d91287fb8177c8d9762188", - "pairedPullRequest": 45, - "updatedAt": "2026-07-27T07:30:00Z", - "purpose": "Pinned merged engine revision for paired local, CI, CodeQL, packaging, and release validation." + "ref": "agent/sv-field-core-p4", + "commit": "bcecea84b50c628ddce699f1ddafa966aa3255f8", + "pairedPullRequest": 47, + "updatedAt": "2026-07-28T09:30:00Z", + "purpose": "Pinned P4 field-core revision for ArSubsv PCAP/PCAPNG, layered health, signal-state, scored SCL binding, known-injection, and support-bundle validation." } diff --git a/src/ARSVIN.Subscriber/Models/PcapFrames.cs b/src/ARSVIN.Subscriber/Models/PcapFrames.cs index 3597312..501b1a7 100644 --- a/src/ARSVIN.Subscriber/Models/PcapFrames.cs +++ b/src/ARSVIN.Subscriber/Models/PcapFrames.cs @@ -1,3 +1,5 @@ +using AR.Iec61850.Capture; + namespace ARSVIN.Subscriber.Models; internal sealed class PcapFrame @@ -6,53 +8,17 @@ internal sealed class PcapFrame public ReadOnlyMemory Frame { get; init; } } +/// +/// Compatibility adapter. File parsing is owned by ARIEC61850 so classic PCAP, PCAPNG, +/// and future offline capture formats feed the same Subscriber protocol pipeline. +/// internal static class PcapFrames { public static IEnumerable Read(string path) - { - using var stream = File.OpenRead(path); - var header = new byte[24]; - if (stream.Read(header) != header.Length) - throw new InvalidDataException("PCAP global header is incomplete."); - - var magic = ReadUInt32Little(header, 0); - var littleEndian = magic switch - { - 0xA1B2C3D4 => true, - 0xD4C3B2A1 => false, - 0xA1B23C4D => true, - 0x4D3CB2A1 => false, - _ => throw new InvalidDataException("Only classic PCAP files are supported. PCAPNG is not supported yet.") - }; - var nano = magic is 0xA1B23C4D or 0x4D3CB2A1; - - var packetHeader = new byte[16]; - while (stream.Read(packetHeader) == packetHeader.Length) - { - var seconds = ReadUInt32(packetHeader, 0, littleEndian); - var fraction = ReadUInt32(packetHeader, 4, littleEndian); - var includedLength = ReadUInt32(packetHeader, 8, littleEndian); - _ = ReadUInt32(packetHeader, 12, littleEndian); - if (includedLength == 0 || includedLength > 262_144) - throw new InvalidDataException($"Invalid PCAP packet length: {includedLength}."); - - var data = new byte[includedLength]; - var read = stream.Read(data); - if (read != data.Length) - throw new InvalidDataException("PCAP packet data is truncated."); - - var ticks = nano ? fraction / 100 : fraction * 10; - var timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds).AddTicks(ticks); - yield return new PcapFrame { Timestamp = timestamp, Frame = data }; - } - } - - private static uint ReadUInt32(byte[] source, int offset, bool littleEndian) - => littleEndian ? ReadUInt32Little(source, offset) : ReadUInt32Big(source, offset); - - private static uint ReadUInt32Little(byte[] source, int offset) - => (uint)(source[offset] | (source[offset + 1] << 8) | (source[offset + 2] << 16) | (source[offset + 3] << 24)); - - private static uint ReadUInt32Big(byte[] source, int offset) - => (uint)((source[offset] << 24) | (source[offset + 1] << 16) | (source[offset + 2] << 8) | source[offset + 3]); + => ProcessBusCaptureFileReader.Read(path) + .Select(packet => new PcapFrame + { + Timestamp = packet.Timestamp, + Frame = packet.Frame + }); } diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs new file mode 100644 index 0000000..01c4864 --- /dev/null +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs @@ -0,0 +1,183 @@ +using System.Collections.Specialized; +using System.Globalization; +using System.Text.RegularExpressions; +using AR.Iec61850.SampledValues.Field; +using AR.Iec61850.SampledValues.Measurements; +using ARSVIN.Subscriber.Models; + +namespace ARSVIN.Subscriber.ViewModels; + +public sealed partial class SvStreamViewModel +{ + private bool _refreshingFieldMode; + private string _captureFieldState = "UNKNOWN"; + private string _protocolFieldState = "UNKNOWN"; + private string _streamFieldState = "UNKNOWN"; + private string _configurationFieldState = "UNKNOWN"; + private string _measurementFieldState = "UNKNOWN"; + private string _measurementFieldDetail = "Measurement semantics unresolved"; + private string _fieldSummary = "Waiting for field evidence"; + private string _signalState = "UNRESOLVED"; + + public SvStreamViewModel() + { + PropertyChanged += (_, args) => + { + if (args.PropertyName is nameof(Packets) or nameof(Health) or nameof(HealthDetail) or nameof(Issues) or + nameof(Bound) or nameof(SclMatch) or nameof(Scaling) or nameof(Timebase) or nameof(MeasurementContext)) + RefreshFieldMode(); + }; + ((INotifyCollectionChanged)_values).CollectionChanged += (_, _) => RefreshFieldMode(); + ((INotifyCollectionChanged)_waveformPoints).CollectionChanged += (_, _) => RefreshFieldMode(); + } + + public string CaptureFieldState { get => _captureFieldState; private set => SetProperty(ref _captureFieldState, value); } + public string ProtocolFieldState { get => _protocolFieldState; private set => SetProperty(ref _protocolFieldState, value); } + public string StreamFieldState { get => _streamFieldState; private set => SetProperty(ref _streamFieldState, value); } + public string ConfigurationFieldState { get => _configurationFieldState; private set => SetProperty(ref _configurationFieldState, value); } + public string MeasurementFieldState { get => _measurementFieldState; private set => SetProperty(ref _measurementFieldState, value); } + public string MeasurementFieldDetail { get => _measurementFieldDetail; private set => SetProperty(ref _measurementFieldDetail, value); } + public string FieldSummary { get => _fieldSummary; private set => SetProperty(ref _fieldSummary, value); } + public string SignalState { get => _signalState; private set => SetProperty(ref _signalState, value); } + + private void RefreshFieldMode() + { + if (_refreshingFieldMode) + return; + + _refreshingFieldMode = true; + try + { + var frames = ParseLong(Packets); + var issueCounts = ParseIssueCounts(Issues); + var representative = ResolveRepresentativeSeries(_waveformPoints); + var samplesPerCycle = ParseSamplesPerCycle(Timebase); + var signal = representative.Count >= 16 + ? SvSignalStateAnalyzer.Analyze(representative, new SvSignalAnalysisOptions { SamplesPerCycle = samplesPerCycle }) + : null; + var comparison = BuildDisplayComparison(SclMatch); + var semanticMapping = !string.IsNullOrWhiteSpace(Bound) && + !Bound.Contains("Unbound", StringComparison.OrdinalIgnoreCase); + var engineeringScaling = _values.Any(value => value.HasEngineeringValue && + !string.Equals(value.EngineeringUnit, "count", StringComparison.OrdinalIgnoreCase)); + var validatedScaling = _values.Any(value => value.ScalingConfidence == SvEngineeringScaleConfidence.DeviceValidated); + + var report = SvFieldHealthEvaluator.Evaluate(new SvFieldHealthInput + { + RawFrameCount = frames, + SvFrameCount = frames, + ParseErrorCount = 0, + SequenceGapCount = issueCounts.Gap, + DuplicateCount = issueCounts.Duplicate, + OutOfOrderCount = issueCounts.OutOfOrder, + PayloadIssueCount = issueCounts.Payload, + ConfigurationComparison = comparison, + IsSclBound = Bound.StartsWith("SCL:", StringComparison.OrdinalIgnoreCase), + HasSemanticMapping = semanticMapping, + HasEngineeringScaling = engineeringScaling, + IsScalingValidated = validatedScaling, + Signal = signal + }); + + CaptureFieldState = Label(report.Capture.State); + ProtocolFieldState = Label(report.Protocol.State); + StreamFieldState = Label(report.Stream.State); + ConfigurationFieldState = Label(report.Configuration.State); + MeasurementFieldState = Label(report.Measurement.State); + MeasurementFieldDetail = report.Measurement.Summary; + SignalState = signal?.State.ToString().ToUpperInvariant() ?? "UNRESOLVED"; + FieldSummary = $"CAPTURE {CaptureFieldState} · PROTOCOL {ProtocolFieldState} · STREAM {StreamFieldState} · CONFIG {ConfigurationFieldState} · MEAS {MeasurementFieldState}"; + + for (var index = _evidenceDetails.Count - 1; index >= 0; index--) + { + var existing = _evidenceDetails[index]; + if (existing.StartsWith("FIELD ·", StringComparison.Ordinal) || existing.StartsWith("SIGNAL ·", StringComparison.Ordinal)) + _evidenceDetails.RemoveAt(index); + } + + var fieldLines = new[] + { + $"FIELD · CAPTURE · {CaptureFieldState} · {report.Capture.Summary}", + $"FIELD · PROTOCOL · {ProtocolFieldState} · {report.Protocol.Summary}", + $"FIELD · STREAM · {StreamFieldState} · {report.Stream.Summary}", + $"FIELD · CONFIGURATION · {ConfigurationFieldState} · {report.Configuration.Summary}", + $"FIELD · MEASUREMENT · {MeasurementFieldState} · {report.Measurement.Summary}", + signal is null ? "SIGNAL · unresolved" : $"SIGNAL · {signal.Summary}" + }; + foreach (var line in fieldLines.Reverse()) + _evidenceDetails.Insert(0, line); + } + finally + { + _refreshingFieldMode = false; + } + } + + private static IReadOnlyList ResolveRepresentativeSeries(IEnumerable points) + { + var materialized = points.ToArray(); + var candidates = new Func[] + { + point => point.Ia, point => point.Ib, point => point.Ic, point => point.In, + point => point.Va, point => point.Vb, point => point.Vc, point => point.Vn + }; + foreach (var selector in candidates) + { + var values = materialized.Select(selector).Where(value => value.HasValue).Select(value => value!.Value).ToArray(); + if (values.Length >= 16) + return values; + } + return Array.Empty(); + } + + private static double? ParseSamplesPerCycle(string value) + { + var match = Regex.Match(value ?? string.Empty, @"(?\d+(?:[\.,]\d+)?)\s+smp/cycle", RegexOptions.IgnoreCase); + return match.Success && double.TryParse(match.Groups["value"].Value.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private static long ParseLong(string value) + => long.TryParse(value, NumberStyles.Number, CultureInfo.CurrentCulture, out var parsed) + ? parsed + : long.TryParse((value ?? string.Empty).Replace(",", string.Empty, StringComparison.Ordinal), out parsed) + ? parsed + : 0; + + private static (int Gap, int Duplicate, int OutOfOrder, int Payload) ParseIssueCounts(string value) + { + static int Read(string text, string label) + { + var match = Regex.Match(text, $@"{Regex.Escape(label)}\s+(?\d+)", RegexOptions.IgnoreCase); + return match.Success && int.TryParse(match.Groups["value"].Value, out var parsed) ? parsed : 0; + } + return (Read(value, "gap"), Read(value, "dup"), Read(value, "order"), Read(value, "payload")); + } + + private static AR.Iec61850.SampledValues.Profiles.SvConfigurationComparisonResult? BuildDisplayComparison(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Contains("Not configured", StringComparison.OrdinalIgnoreCase)) + return null; + if (value.Contains("error", StringComparison.OrdinalIgnoreCase)) + return new AR.Iec61850.SampledValues.Profiles.SvConfigurationComparisonResult + { + Findings = [new(AR.Iec61850.SampledValues.Profiles.SvConfigurationFindingSeverity.Error, "FIELD_CONFIG", "SCL", "configured", "observed", value)] + }; + if (value.Contains("warning", StringComparison.OrdinalIgnoreCase)) + return new AR.Iec61850.SampledValues.Profiles.SvConfigurationComparisonResult + { + Findings = [new(AR.Iec61850.SampledValues.Profiles.SvConfigurationFindingSeverity.Warning, "FIELD_CONFIG", "SCL", "configured", "observed", value)] + }; + return new AR.Iec61850.SampledValues.Profiles.SvConfigurationComparisonResult(); + } + + private static string Label(SvFieldHealthState state) => state switch + { + SvFieldHealthState.Good => "GOOD", + SvFieldHealthState.Quiet => "QUIET", + SvFieldHealthState.Warning => "WARN", + SvFieldHealthState.Bad => "BAD", + _ => "UNKNOWN" + }; +} diff --git a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs index 7dc966a..8443dac 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs @@ -1,8 +1,15 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Windows; using System.Windows.Threading; +using AR.Iec61850.Capture; using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Field; +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Profiles; using AR.Iec61850.SampledValues.Reporting; using AR.Iec61850.Scl; @@ -25,6 +32,7 @@ public sealed class SvSubscriberViewModel : ObservableObject, IDisposable private Task? _captureTask; private IReadOnlyList _sclProfiles = Array.Empty(); private string _selectedSclPath = string.Empty; + private string _captureSourcePath = string.Empty; private string _statusText = "Ready. Select adapter, optionally import SCL, then start listening."; private string _healthText = "IDLE"; private string _captureButtonText = "Start"; @@ -57,14 +65,12 @@ public SvSubscriberViewModel() _uiTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; _uiTimer.Tick += (_, _) => RefreshUiSnapshots(); _uiTimer.Start(); - RefreshAdapters(); } public ObservableCollection Adapters { get; } = new(); public ObservableCollection Streams { get; } = new(); public BulkObservableCollection SelectedValues { get; } = new(); - public RelayCommand RefreshAdaptersCommand { get; } public AsyncRelayCommand OpenSclCommand { get; } public AsyncRelayCommand OpenCaptureFileCommand { get; } @@ -72,36 +78,19 @@ public SvSubscriberViewModel() public RelayCommand ClearCommand { get; } public AsyncRelayCommand ExportReportCommand { get; } - public AdapterChoice? SelectedAdapter - { - get => _selectedAdapter; - set => SetProperty(ref _selectedAdapter, value); - } - + public AdapterChoice? SelectedAdapter { get => _selectedAdapter; set => SetProperty(ref _selectedAdapter, value); } public SvStreamViewModel? SelectedStream { get => _selectedStream; - set - { - if (SetProperty(ref _selectedStream, value)) - RefreshSelectedValues(); - } + set { if (SetProperty(ref _selectedStream, value)) RefreshSelectedValues(); } } - - public string FilterText - { - get => _filterText; - set => SetProperty(ref _filterText, value); - } - + public string FilterText { get => _filterText; set => SetProperty(ref _filterText, value); } public bool IsCapturing { get => _isCapturing; private set { - if (!SetProperty(ref _isCapturing, value)) - return; - + if (!SetProperty(ref _isCapturing, value)) return; CaptureButtonText = value ? "Stop" : "Start"; RefreshAdaptersCommand.RaiseCanExecuteChanged(); OpenSclCommand.RaiseCanExecuteChanged(); @@ -144,16 +133,12 @@ private void RefreshAdapters() MacAddress = adapter.MacAddress?.ToString() ?? string.Empty }); } - SelectedAdapter ??= Adapters.FirstOrDefault(); StatusText = Adapters.Count == 0 ? "No Npcap adapter found. Install Npcap and run as Administrator if needed." : $"Detected {Adapters.Count} adapter(s)."; } - catch (Exception ex) - { - StatusText = $"Adapter refresh failed: {ex.Message}"; - } + catch (Exception ex) { StatusText = $"Adapter refresh failed: {ex.Message}"; } } private async Task OpenSclAsync() @@ -163,9 +148,7 @@ private async Task OpenSclAsync() Title = "Open SCL/SCD file for SV verification", Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid)|*.scd;*.cid;*.icd;*.iid|XML files (*.xml)|*.xml|All files (*.*)|*.*" }; - - if (dialog.ShowDialog() != true) - return; + if (dialog.ShowDialog() != true) return; try { @@ -173,7 +156,7 @@ private async Task OpenSclAsync() _sclProfiles = SampledValuesPublisherProfile.CreateMany(document); _selectedSclPath = dialog.FileName; SclText = $"{Path.GetFileName(dialog.FileName)} • {_sclProfiles.Count} SV stream(s)"; - StatusText = $"SCL loaded: {_sclProfiles.Count} SampledValueControl stream(s) available for binding."; + StatusText = $"SCL loaded: {_sclProfiles.Count} SampledValueControl stream(s) available for evidence-scored binding."; } catch (Exception ex) { @@ -188,43 +171,38 @@ private async Task OpenCaptureFileAsync() { var dialog = new OpenFileDialog { - Title = "Open PCAP file with IEC 61850 Sampled Values", - Filter = "PCAP capture (*.pcap)|*.pcap|All files (*.*)|*.*" + Title = "Open PCAP or PCAPNG with IEC 61850 Sampled Values", + Filter = "Process-bus capture (*.pcap;*.pcapng)|*.pcap;*.pcapng|Classic PCAP (*.pcap)|*.pcap|PCAPNG (*.pcapng)|*.pcapng|All files (*.*)|*.*" }; - - if (dialog.ShowDialog() != true) - return; + if (dialog.ShowDialog() != true) return; try { ClearRuntimeOnly(); _captureStarted = DateTimeOffset.Now; + _captureSourcePath = dialog.FileName; var count = await Task.Run(() => ReplayPcapFile(dialog.FileName)).ConfigureAwait(true); StatusText = $"Processed {count:N0} frame(s) from {Path.GetFileName(dialog.FileName)}."; RefreshUiSnapshots(); } - catch (Exception ex) when (ex is IOException or InvalidDataException or ArgumentException) + catch (Exception ex) when (ex is IOException or InvalidDataException or ArgumentException or OverflowException) { - StatusText = $"PCAP import failed: {ex.Message}"; + StatusText = $"Capture import failed: {ex.Message}"; } } private int ReplayPcapFile(string path) { var total = 0; - foreach (var frame in PcapFrames.Read(path)) + foreach (var packet in ProcessBusCaptureFileReader.Read(path)) { total++; - ObserveFrame(frame.Timestamp, frame.Frame, SvObservationInputKind.PcapReplay); + ObserveFrame(packet.Timestamp, packet.Frame, SvObservationInputKind.PcapReplay); } - return total; } - private void ObserveFrame( - DateTimeOffset timestamp, - ReadOnlyMemory ethernetFrame, - SvObservationInputKind inputKind) + private void ObserveFrame(DateTimeOffset timestamp, ReadOnlyMemory ethernetFrame, SvObservationInputKind inputKind) { Interlocked.Increment(ref _rawFrames); if (!SampledValuesFrameParser.TryParseEthernetFrame(ethernetFrame, out var frame)) @@ -232,7 +210,6 @@ private void ObserveFrame( Interlocked.Increment(ref _parseErrors); return; } - if (!PassesUserFilter(frame)) { Interlocked.Increment(ref _droppedByFilter); @@ -251,40 +228,22 @@ private void ObserveFrame( var key = observation.Key.Id; _latestObservations[key] = observation; var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key)); - runtime.Observe( - timestamp, - frame, - observation.IsBoundToScl ? profile : null, - observation); + runtime.Observe(timestamp, frame, observation.IsBoundToScl ? profile : null, observation); } - private void ToggleCapture() - { - if (IsCapturing) - { - StopCapture(); - return; - } - - StartCapture(); - } + private void ToggleCapture() { if (IsCapturing) StopCapture(); else StartCapture(); } private void StartCapture() { - if (SelectedAdapter is null) - { - StatusText = "Select an Npcap adapter before starting."; - return; - } - + if (SelectedAdapter is null) { StatusText = "Select an Npcap adapter before starting."; return; } ClearRuntimeOnly(); + _captureSourcePath = $"live://{SelectedAdapter.DisplayName}"; IsCapturing = true; _captureStarted = DateTimeOffset.Now; HealthText = "LISTENING"; StatusText = "Listening for IEC 61850 Sampled Values frames..."; _captureCts = new CancellationTokenSource(); - var selector = SelectedAdapter.Selector; - _captureTask = Task.Run(() => CaptureLoopAsync(selector, _captureCts.Token)); + _captureTask = Task.Run(() => CaptureLoopAsync(SelectedAdapter.Selector, _captureCts.Token)); } private void StopCapture() @@ -306,16 +265,10 @@ private async Task CaptureLoopAsync(string adapterSelector, CancellationToken ca BufferCapacity = 8192, ReadTimeoutMilliseconds = 250 }; - await foreach (var captured in source.CaptureAsync(options, cancellationToken).ConfigureAwait(false)) - { ObserveFrame(captured.Timestamp, captured.Frame, SvObservationInputKind.LiveCapture); - } - } - catch (OperationCanceledException) - { - // Expected on stop. } + catch (OperationCanceledException) { } catch (Exception ex) { await Application.Current.Dispatcher.InvokeAsync(() => @@ -330,13 +283,10 @@ await Application.Current.Dispatcher.InvokeAsync(() => private bool PassesUserFilter(SampledValuesFrame frame) { var filter = FilterText?.Trim(); - if (string.IsNullOrWhiteSpace(filter)) - return true; - + if (string.IsNullOrWhiteSpace(filter)) return true; var first = frame.Pdu.Asdus.FirstOrDefault(); if (ushort.TryParse(filter.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, null, out var appId) && frame.AppId == appId) return true; - return (first?.SvId?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) || frame.Source.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) || frame.Destination.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); @@ -344,42 +294,48 @@ private bool PassesUserFilter(SampledValuesFrame frame) private SampledValuesPublisherProfile? FindProfile(SampledValuesFrame frame, SampledValueAsdu? asdu) { - if (asdu is null || _sclProfiles.Count == 0) - return null; + if (asdu is null || _sclProfiles.Count == 0) return null; - var addressCandidates = _sclProfiles.Where(profile => - profile.AppId == frame.AppId && - string.Equals(profile.Destination.ToString(), frame.Destination.ToString(), StringComparison.OrdinalIgnoreCase) && - profile.Vlan?.VlanId == frame.Vlan?.VlanId) + var observation = new SvSclBindingObservation + { + AppId = frame.AppId, + DestinationMac = frame.Destination.ToString(), + VlanId = frame.Vlan?.VlanId, + SvId = asdu.SvId, + DataSetReference = asdu.DataSetReference, + ConfigurationRevision = asdu.ConfigurationRevision, + AsduPerFrame = frame.Pdu.Asdus.Count, + PayloadBytesPerAsdu = asdu.SamplePayload.Length + }; + var ranked = _sclProfiles.Select(profile => new + { + Profile = profile, + Result = SvSclBindingScorer.Score(new SvSclBindingCandidate + { + CandidateId = profile.Stream.ControlBlockReference, + ExpectedAppId = profile.AppId, + ExpectedDestinationMac = profile.Destination.ToString(), + ExpectedVlanId = profile.Vlan?.VlanId, + ExpectedSvId = profile.Stream.SvId, + ExpectedDataSetReference = profile.Stream.DataSetReference, + ExpectedConfigurationRevision = profile.Stream.ConfigurationRevision, + ExpectedAsduPerFrame = profile.AsduPerFrame, + ExpectedPayloadBytesPerAsdu = profile.PayloadLayout.PayloadByteLength + }, observation) + }) + .Where(item => item.Result.Confidence is SvSclBindingConfidence.Likely or SvSclBindingConfidence.Confirmed) + .OrderByDescending(item => item.Result.Confidence) + .ThenByDescending(item => item.Result.Score) .ToArray(); - if (addressCandidates.Length == 0) + if (ranked.Length == 0) return null; + if (ranked.Length > 1 && ranked[0].Result.Confidence == ranked[1].Result.Confidence && ranked[0].Result.Score == ranked[1].Result.Score) return null; - - var exact = addressCandidates.Where(profile => - string.Equals(profile.Stream.SvId, asdu.SvId, StringComparison.Ordinal) && - string.Equals(profile.Stream.DataSetReference, asdu.DataSetReference, StringComparison.Ordinal)) - .ToArray(); - if (exact.Length == 1) - return exact[0]; - - var svIdMatches = addressCandidates.Where(profile => - string.Equals(profile.Stream.SvId, asdu.SvId, StringComparison.Ordinal)) - .ToArray(); - if (svIdMatches.Length == 1) - return svIdMatches[0]; - - var dataSetMatches = addressCandidates.Where(profile => - string.Equals(profile.Stream.DataSetReference, asdu.DataSetReference, StringComparison.Ordinal)) - .ToArray(); - if (dataSetMatches.Length == 1) - return dataSetMatches[0]; - - return null; + return ranked[0].Profile; } private void RefreshUiSnapshots() { - var snapshots = _runtimeStreams.Values.Select(x => x.Snapshot()).OrderBy(x => x.AppId).ThenBy(x => x.SvId).ToArray(); + var snapshots = _runtimeStreams.Values.Select(runtime => runtime.Snapshot()).OrderBy(snapshot => snapshot.AppId).ThenBy(snapshot => snapshot.SvId).ToArray(); foreach (var snapshot in snapshots) { if (!_streamRows.TryGetValue(snapshot.Key, out var row)) @@ -389,28 +345,23 @@ private void RefreshUiSnapshots() Streams.Add(row); SelectedStream ??= row; } - _latestObservations.TryGetValue(snapshot.Key, out var observation); row.Apply(snapshot, observation); } - var keys = snapshots.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var stale in _streamRows.Keys.Where(k => !keys.Contains(k)).ToArray()) + var keys = snapshots.Select(snapshot => snapshot.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var stale in _streamRows.Keys.Where(key => !keys.Contains(key)).ToArray()) { var row = _streamRows[stale]; _streamRows.Remove(stale); Streams.Remove(row); } - RefreshSelectedValues(); UpdateGlobalCards(snapshots); ExportReportCommand.RaiseCanExecuteChanged(); } - private void RefreshSelectedValues() - { - SelectedValues.ReplaceAll(SelectedStream?.Values ?? Array.Empty()); - } + private void RefreshSelectedValues() => SelectedValues.ReplaceAll(SelectedStream?.Values ?? Array.Empty()); private void UpdateGlobalCards(IReadOnlyList snapshots) { @@ -418,11 +369,8 @@ private void UpdateGlobalCards(IReadOnlyList snapshots) var sv = Interlocked.Read(ref _svFrames); var parse = Interlocked.Read(ref _parseErrors); var dropped = Interlocked.Read(ref _droppedByFilter); - var runtimeIssues = snapshots.Sum(x => x.SequenceGapCount + x.DuplicateCount + x.OutOfOrderCount + x.PayloadIssueCount); - var configurationIssues = snapshots.Sum(snapshot => - _latestObservations.TryGetValue(snapshot.Key, out var observation) - ? observation.ConfigurationComparison?.Findings.Count ?? 0 - : 0); + var runtimeIssues = snapshots.Sum(snapshot => snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + snapshot.PayloadIssueCount); + var configurationIssues = snapshots.Sum(snapshot => _latestObservations.TryGetValue(snapshot.Key, out var observation) ? observation.ConfigurationComparison?.Findings.Count ?? 0 : 0); var issues = runtimeIssues + configurationIssues + parse; var duration = _captureStarted.HasValue ? DateTimeOffset.Now - _captureStarted.Value : TimeSpan.Zero; var fps = duration.TotalSeconds > 0.001 ? sv / duration.TotalSeconds : 0; @@ -434,31 +382,20 @@ private void UpdateGlobalCards(IReadOnlyList snapshots) CaptureDurationText = duration.ToString(@"hh\:mm\:ss"); GlobalFpsText = $"{fps:0.0} fps"; - if (!IsCapturing && snapshots.Count == 0) - HealthText = "IDLE"; - else if (_streamRows.Values.Any(row => row.Health == "BAD") || parse > 0) - HealthText = "BAD"; - else if (_streamRows.Values.Any(row => row.Health == "WARN")) - HealthText = "WARN"; - else if (snapshots.Count == 0) - HealthText = IsCapturing ? "LISTENING" : "IDLE"; - else - HealthText = "GOOD"; - - if (dropped > 0 && IsCapturing) - StatusText = $"Listening. User filter dropped {dropped:N0} SV frame(s)."; + if (!IsCapturing && snapshots.Count == 0) HealthText = "IDLE"; + else if (_streamRows.Values.Any(row => row.CaptureFieldState == "BAD" || row.ProtocolFieldState == "BAD" || row.StreamFieldState == "BAD") || (parse > 0 && sv == 0)) HealthText = "BAD"; + else if (_streamRows.Values.Any(row => row.CaptureFieldState == "WARN" || row.ProtocolFieldState == "WARN" || row.StreamFieldState == "WARN" || row.ConfigurationFieldState is "WARN" or "BAD" || row.MeasurementFieldState == "WARN") || parse > 0) HealthText = "WARN"; + else if (snapshots.Count == 0) HealthText = IsCapturing ? "LISTENING" : "IDLE"; + else HealthText = "GOOD"; + + if (dropped > 0 && IsCapturing) StatusText = $"Listening. User filter dropped {dropped:N0} SV frame(s)."; } private void Clear() { ClearRuntimeOnly(); - Streams.Clear(); - SelectedValues.ReplaceAll(Array.Empty()); - _streamRows.Clear(); - SelectedStream = null; HealthText = "IDLE"; StatusText = "Cleared subscriber statistics."; - ExportReportCommand.RaiseCanExecuteChanged(); } private void ClearRuntimeOnly() @@ -475,6 +412,7 @@ private void ClearRuntimeOnly() Interlocked.Exchange(ref _parseErrors, 0); Interlocked.Exchange(ref _droppedByFilter, 0); _captureStarted = null; + _captureSourcePath = string.Empty; UpdateGlobalCards(Array.Empty()); ExportReportCommand.RaiseCanExecuteChanged(); } @@ -483,28 +421,19 @@ private async Task ExportReportAsync() { var dialog = new SaveFileDialog { - Title = "Export SV subscriber evidence bundle", - Filter = "ARSVIN evidence bundle (*.md)|*.md|Markdown report (*.md)|*.md|JSON evidence (*.json)|*.json", - DefaultExt = ".md", + Title = "Export ArSubsv field support bundle", + Filter = "ArSubsv support bundle (*.zip)|*.zip", + DefaultExt = ".zip", AddExtension = true, - FileName = $"arsvin-subscriber-evidence-{DateTime.Now:yyyyMMdd-HHmmss}.md" + FileName = $"ArSubsv-Support-{DateTime.Now:yyyyMMdd-HHmmss}.zip" }; - - if (dialog.ShowDialog() != true) - return; + if (dialog.ShowDialog() != true) return; try { var generatedAt = DateTimeOffset.Now; - var snapshots = _runtimeStreams.Values - .Select(runtime => runtime.Snapshot()) - .OrderBy(snapshot => snapshot.AppId) - .ThenBy(snapshot => snapshot.SvId) - .ToArray(); - var observations = _latestObservations.ToDictionary( - pair => pair.Key, - pair => pair.Value, - StringComparer.Ordinal); + var snapshots = _runtimeStreams.Values.Select(runtime => runtime.Snapshot()).OrderBy(snapshot => snapshot.AppId).ThenBy(snapshot => snapshot.SvId).ToArray(); + var observations = _latestObservations.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); var report = SvSubscriberReportBuilder.Build(new SvSubscriberReportContext { GeneratedAt = generatedAt, @@ -521,19 +450,97 @@ private async Task ExportReportAsync() Observations = observations }); - var markdownPath = Path.ChangeExtension(dialog.FileName, ".md"); - var jsonPath = Path.ChangeExtension(dialog.FileName, ".json"); var markdown = SvSubscriberEvidenceReportSerializer.ToMarkdown(report); var json = SvSubscriberEvidenceReportSerializer.ToJson(report); - await Task.WhenAll( - File.WriteAllTextAsync(markdownPath, markdown), - File.WriteAllTextAsync(jsonPath, json)).ConfigureAwait(true); + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + var contents = new List + { + SvSupportBundleWriter.Text("subscriber-evidence.md", markdown, "Human-readable receiver evidence"), + SvSupportBundleWriter.Text("subscriber-evidence.json", json, "Machine-readable receiver evidence"), + SvSupportBundleWriter.Text("field-summary.json", JsonSerializer.Serialize(BuildFieldSummary(), jsonOptions), "Five-axis field health and selected stream state"), + SvSupportBundleWriter.Text("diagnostics.md", BuildDiagnosticsMarkdown(), "Selected stream diagnostics and provenance") + }; + if (SelectedStream?.ActiveMeasurementContext is { } context) + { + var contextDocument = new SvMeasurementContextDocument { Streams = [context] }; + contents.Add(SvSupportBundleWriter.Text("measurement-context.json", SvMeasurementContextSerializer.ToJson(contextDocument), "Explicit CT/VT and display-domain context")); + } - StatusText = $"Evidence bundle exported: {Path.GetFileName(markdownPath)} + {Path.GetFileName(jsonPath)}"; + var appAssembly = typeof(SvSubscriberViewModel).Assembly; + var engineAssembly = typeof(SampledValuesFrame).Assembly; + var manifest = new SvSupportBundleManifest + { + GeneratedAt = generatedAt, + Application = "ArSubsv", + ApplicationVersion = appAssembly.GetName().Version?.ToString() ?? string.Empty, + ApplicationCommit = ResolveRevision(appAssembly), + EngineCommit = ResolveRevision(engineAssembly), + CaptureSource = string.IsNullOrWhiteSpace(_captureSourcePath) ? "live or unspecified" : Path.GetFileName(_captureSourcePath), + SclSha256 = ComputeFileSha256(_selectedSclPath), + PrivacyMode = SvSupportBundlePrivacyMode.MetadataOnly + }; + + await Task.Run(() => SvSupportBundleWriter.Write(dialog.FileName, manifest, contents)).ConfigureAwait(true); + StatusText = $"Support bundle exported: {Path.GetFileName(dialog.FileName)}"; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or CryptographicException) { - StatusText = $"Evidence export failed: {ex.Message}"; + StatusText = $"Support bundle export failed: {ex.Message}"; } } + + private object BuildFieldSummary() + => new + { + health = HealthText, + captureSource = string.IsNullOrWhiteSpace(_captureSourcePath) ? "unspecified" : Path.GetFileName(_captureSourcePath), + scl = string.IsNullOrWhiteSpace(_selectedSclPath) ? "not loaded" : Path.GetFileName(_selectedSclPath), + selectedStream = SelectedStream is null ? null : new + { + SelectedStream.Key, + SelectedStream.AppId, + SelectedStream.SvId, + capture = SelectedStream.CaptureFieldState, + protocol = SelectedStream.ProtocolFieldState, + stream = SelectedStream.StreamFieldState, + configuration = SelectedStream.ConfigurationFieldState, + measurement = SelectedStream.MeasurementFieldState, + signal = SelectedStream.SignalState, + SelectedStream.FieldSummary, + SelectedStream.MeasurementFieldDetail, + SelectedStream.Scaling, + SelectedStream.Timebase, + SelectedStream.MeasurementContext + } + }; + + private string BuildDiagnosticsMarkdown() + { + var builder = new StringBuilder("# ArSubsv Field Diagnostics\n\n"); + builder.AppendLine($"- Capture source: {(string.IsNullOrWhiteSpace(_captureSourcePath) ? "unspecified" : Path.GetFileName(_captureSourcePath))}"); + builder.AppendLine($"- SCL: {(string.IsNullOrWhiteSpace(_selectedSclPath) ? "not loaded" : Path.GetFileName(_selectedSclPath))}"); + builder.AppendLine($"- Global health: {HealthText}"); + if (SelectedStream is null) return builder.ToString(); + builder.AppendLine($"- Selected stream: {SelectedStream.SvId} / {SelectedStream.AppId}"); + builder.AppendLine($"- {SelectedStream.FieldSummary}"); + builder.AppendLine(); + builder.AppendLine("## Evidence"); + foreach (var line in SelectedStream.EvidenceDetails) builder.Append("- ").AppendLine(line); + return builder.ToString(); + } + + private static string ResolveRevision(Assembly assembly) + { + var informational = assembly.GetCustomAttribute()?.InformationalVersion ?? string.Empty; + var plus = informational.LastIndexOf('+'); + var candidate = plus >= 0 ? informational[(plus + 1)..] : informational; + return candidate.Length >= 40 && candidate.Take(40).All(Uri.IsHexDigit) ? candidate[..40].ToLowerInvariant() : informational; + } + + private static string ComputeFileSha256(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return string.Empty; + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } } diff --git a/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs b/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs new file mode 100644 index 0000000..1787f1a --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs @@ -0,0 +1,63 @@ +using AR.Iec61850.SampledValues.Measurements; +using ARSVIN.Subscriber.Models; +using ARSVIN.Subscriber.ViewModels; + +namespace ARSVIN.Tests.SampledValues; + +public sealed class FieldModeViewModelTests +{ + [Fact] + public void RawUnboundStreamRemainsOperationalAndMeasurementUnknown() + { + var viewModel = new SvStreamViewModel(); + viewModel.Apply(new SvStreamSnapshot + { + Key = "stream-1", + Health = "GOOD", + AppId = 0x4001, + SvId = "MU01", + FrameCount = 100, + ActualFps = 4800, + LayoutBinding = "Unbound raw payload", + ScalingSummary = "Raw counts", + Values = [new DecodedValueRow { Index = 1, Signal = "Element 1", Kind = "INT32", Value = "1", Raw = "00000001" }] + }, null); + + Assert.Equal("GOOD", viewModel.CaptureFieldState); + Assert.Equal("GOOD", viewModel.ProtocolFieldState); + Assert.Equal("GOOD", viewModel.StreamFieldState); + Assert.Equal("UNKNOWN", viewModel.ConfigurationFieldState); + Assert.Contains(viewModel.MeasurementFieldState, new[] { "UNKNOWN", "WARN" }); + } + + [Fact] + public void NoiseDominatedWaveformIsReportedWithoutZeroingSamples() + { + var random = new Random(615); + var waveform = Enumerable.Range(0, 160).Select(index => new WaveformPoint + { + Index = index, + SampleCount = (ushort)index, + Ia = random.NextDouble() * 2 - 1, + CurrentUnit = "count" + }).ToArray(); + var viewModel = new SvStreamViewModel(); + viewModel.Apply(new SvStreamSnapshot + { + Key = "stream-quiet", + Health = "GOOD", + AppId = 0x4001, + SvId = "MU01", + FrameCount = 160, + SamplesPerCycle = 80, + NominalFrequencyHz = 60, + LayoutBinding = "SCL: MU01/LLN0.MSVCB", + ScalingSummary = "Raw counts", + WaveformPoints = waveform + }, null); + + Assert.Equal("NOISEDOMINATED", viewModel.SignalState); + Assert.Contains(viewModel.EvidenceDetails, line => line.StartsWith("FIELD · MEASUREMENT", StringComparison.Ordinal)); + Assert.Contains(viewModel.WaveformPoints, point => point.Ia != 0); + } +} From 6d3d5c3c389170211a96bb21b572b8d87e7bf59f Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:17:56 +0700 Subject: [PATCH 02/11] Record the rebased Field Mode test boundary --- docs/arsubsv-field-mode-p4.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/arsubsv-field-mode-p4.md b/docs/arsubsv-field-mode-p4.md index 17ae6a9..67fd2f1 100644 --- a/docs/arsubsv-field-mode-p4.md +++ b/docs/arsubsv-field-mode-p4.md @@ -2,6 +2,8 @@ Field Mode is the practical receiver workflow for live IEC 61850 Sampled Values and offline PCAP/PCAPNG analysis. +The P4 application branch is rebuilt directly on merged `main` and pins the field-core engine by its exact commit SHA. The draft PR therefore contains only Field Mode changes and remains safe to test locally without merging. + ## Five evidence axes ArSubsv separates: From 4ae2a52dd896f54e6a2b3965977f911d3409fa7c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:20:01 +0700 Subject: [PATCH 03/11] Show five field-health axes above the waveform --- .../MainWindow.FieldModeUi.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs diff --git a/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs new file mode 100644 index 0000000..a635e7d --- /dev/null +++ b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs @@ -0,0 +1,126 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; + +namespace ARSVIN.Subscriber; + +public partial class MainWindow +{ + private bool _fieldModeUiAttached; + + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + AttachFieldHealthStrip(); + } + + private void AttachFieldHealthStrip() + { + if (_fieldModeUiAttached || ScopeHost.Child is not UIElement existingScope) + return; + + _fieldModeUiAttached = true; + ScopeHost.Child = null; + + var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var strip = new Border + { + Margin = new Thickness(0, 0, 0, 7), + Padding = new Thickness(6, 5, 6, 5), + CornerRadius = new CornerRadius(7), + BorderThickness = new Thickness(1), + Background = ResolveBrush("ShellBg", Color.FromRgb(248, 251, 255)), + BorderBrush = ResolveBrush("PanelBorder", Color.FromRgb(220, 230, 243)), + ToolTip = "Operational health is CAPTURE + PROTOCOL + STREAM. CONFIGURATION and MEASUREMENT are independent review axes." + }; + + var axes = new UniformGrid { Columns = 5, Rows = 1 }; + axes.Children.Add(CreateAxisCard("CAPTURE", "SelectedStream.CaptureFieldState")); + axes.Children.Add(CreateAxisCard("PROTOCOL", "SelectedStream.ProtocolFieldState")); + axes.Children.Add(CreateAxisCard("STREAM", "SelectedStream.StreamFieldState")); + axes.Children.Add(CreateAxisCard("CONFIG", "SelectedStream.ConfigurationFieldState")); + axes.Children.Add(CreateAxisCard("MEASUREMENT", "SelectedStream.MeasurementFieldState", "SelectedStream.MeasurementFieldDetail")); + strip.Child = axes; + + Grid.SetRow(strip, 0); + Grid.SetRow(existingScope, 1); + root.Children.Add(strip); + root.Children.Add(existingScope); + ScopeHost.Child = root; + } + + private Border CreateAxisCard(string label, string statePath, string? toolTipPath = null) + { + var card = new Border + { + Margin = new Thickness(3, 0, 3, 0), + Padding = new Thickness(8, 3, 8, 3), + CornerRadius = new CornerRadius(6), + Background = Brushes.White, + BorderBrush = ResolveBrush("PanelBorder", Color.FromRgb(220, 230, 243)), + BorderThickness = new Thickness(1) + }; + + if (!string.IsNullOrWhiteSpace(toolTipPath)) + BindingOperations.SetBinding(card, ToolTipProperty, new Binding(toolTipPath)); + + var panel = new Grid(); + panel.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + panel.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + panel.Children.Add(new TextBlock + { + Text = label, + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = ResolveBrush("Dim", Color.FromRgb(100, 116, 139)), + VerticalAlignment = VerticalAlignment.Center + }); + + var state = new TextBlock + { + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + FontFamily = new FontFamily("Cascadia Mono, Consolas"), + VerticalAlignment = VerticalAlignment.Center, + Style = CreateFieldStateStyle() + }; + Grid.SetColumn(state, 1); + BindingOperations.SetBinding(state, TextBlock.TextProperty, new Binding(statePath) { FallbackValue = "UNKNOWN" }); + panel.Children.Add(state); + + card.Child = panel; + return card; + } + + private Style CreateFieldStateStyle() + { + var style = new Style(typeof(TextBlock)); + style.Setters.Add(new Setter(TextBlock.ForegroundProperty, ResolveBrush("Muted", Color.FromRgb(100, 116, 139)))); + AddStateTrigger(style, "GOOD", ResolveBrush("Green", Color.FromRgb(22, 163, 74))); + AddStateTrigger(style, "QUIET", ResolveBrush("Blue", Color.FromRgb(37, 99, 235))); + AddStateTrigger(style, "WARN", ResolveBrush("Amber", Color.FromRgb(217, 119, 6))); + AddStateTrigger(style, "BAD", ResolveBrush("Red", Color.FromRgb(220, 38, 38))); + AddStateTrigger(style, "ERROR", ResolveBrush("Red", Color.FromRgb(220, 38, 38))); + return style; + } + + private static void AddStateTrigger(Style style, string value, Brush brush) + { + var trigger = new DataTrigger + { + Binding = new Binding("Text") { RelativeSource = new RelativeSource(RelativeSourceMode.Self) }, + Value = value + }; + trigger.Setters.Add(new Setter(TextBlock.ForegroundProperty, brush)); + style.Triggers.Add(trigger); + } + + private Brush ResolveBrush(string resourceKey, Color fallback) + => TryFindResource(resourceKey) as Brush ?? new SolidColorBrush(fallback); +} From 063f2838085feca4d5360d049d192d6133185711 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:22:19 +0700 Subject: [PATCH 04/11] Initialize Field Mode from the existing stream constructor --- .../ViewModels/SvStreamViewModel.FieldMode.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs index 01c4864..1e8f3a0 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.FieldMode.cs @@ -19,7 +19,7 @@ public sealed partial class SvStreamViewModel private string _fieldSummary = "Waiting for field evidence"; private string _signalState = "UNRESOLVED"; - public SvStreamViewModel() + private void InitializeFieldMode() { PropertyChanged += (_, args) => { @@ -29,6 +29,7 @@ public SvStreamViewModel() }; ((INotifyCollectionChanged)_values).CollectionChanged += (_, _) => RefreshFieldMode(); ((INotifyCollectionChanged)_waveformPoints).CollectionChanged += (_, _) => RefreshFieldMode(); + RefreshFieldMode(); } public string CaptureFieldState { get => _captureFieldState; private set => SetProperty(ref _captureFieldState, value); } From e8bab1b48a446344855d919affe89375bc909407 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:22:59 +0700 Subject: [PATCH 05/11] Initialize generic and field presentations from one constructor --- .../ViewModels/SvStreamViewModel.GenericExplorer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs index 689f1ad..97a6096 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs @@ -22,6 +22,7 @@ public SvStreamViewModel() _phasors.CollectionChanged += SourceCollectionChanged; PropertyChanged += StreamPropertyChanged; RefreshGenericPresentation(); + InitializeFieldMode(); } public IReadOnlyList GenericValues => _genericValues; From 37b4bfbdf05d4b4dd7c723e0d589505d62611e6c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:27:23 +0700 Subject: [PATCH 06/11] Keep engine-only regression tests independent of the WPF application --- .../SampledValues/FieldModeViewModelTests.cs | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs b/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs deleted file mode 100644 index 1787f1a..0000000 --- a/tests/ARSVIN.Tests/SampledValues/FieldModeViewModelTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using AR.Iec61850.SampledValues.Measurements; -using ARSVIN.Subscriber.Models; -using ARSVIN.Subscriber.ViewModels; - -namespace ARSVIN.Tests.SampledValues; - -public sealed class FieldModeViewModelTests -{ - [Fact] - public void RawUnboundStreamRemainsOperationalAndMeasurementUnknown() - { - var viewModel = new SvStreamViewModel(); - viewModel.Apply(new SvStreamSnapshot - { - Key = "stream-1", - Health = "GOOD", - AppId = 0x4001, - SvId = "MU01", - FrameCount = 100, - ActualFps = 4800, - LayoutBinding = "Unbound raw payload", - ScalingSummary = "Raw counts", - Values = [new DecodedValueRow { Index = 1, Signal = "Element 1", Kind = "INT32", Value = "1", Raw = "00000001" }] - }, null); - - Assert.Equal("GOOD", viewModel.CaptureFieldState); - Assert.Equal("GOOD", viewModel.ProtocolFieldState); - Assert.Equal("GOOD", viewModel.StreamFieldState); - Assert.Equal("UNKNOWN", viewModel.ConfigurationFieldState); - Assert.Contains(viewModel.MeasurementFieldState, new[] { "UNKNOWN", "WARN" }); - } - - [Fact] - public void NoiseDominatedWaveformIsReportedWithoutZeroingSamples() - { - var random = new Random(615); - var waveform = Enumerable.Range(0, 160).Select(index => new WaveformPoint - { - Index = index, - SampleCount = (ushort)index, - Ia = random.NextDouble() * 2 - 1, - CurrentUnit = "count" - }).ToArray(); - var viewModel = new SvStreamViewModel(); - viewModel.Apply(new SvStreamSnapshot - { - Key = "stream-quiet", - Health = "GOOD", - AppId = 0x4001, - SvId = "MU01", - FrameCount = 160, - SamplesPerCycle = 80, - NominalFrequencyHz = 60, - LayoutBinding = "SCL: MU01/LLN0.MSVCB", - ScalingSummary = "Raw counts", - WaveformPoints = waveform - }, null); - - Assert.Equal("NOISEDOMINATED", viewModel.SignalState); - Assert.Contains(viewModel.EvidenceDetails, line => line.StartsWith("FIELD · MEASUREMENT", StringComparison.Ordinal)); - Assert.Contains(viewModel.WaveformPoints, point => point.Ia != 0); - } -} From a47a0255360c17b6b4a71a482db6bbb94207a483 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:29:52 +0700 Subject: [PATCH 07/11] Add a practical known-injection validation dialog --- src/ARSVIN.Subscriber/KnownInjectionWindow.cs | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/ARSVIN.Subscriber/KnownInjectionWindow.cs diff --git a/src/ARSVIN.Subscriber/KnownInjectionWindow.cs b/src/ARSVIN.Subscriber/KnownInjectionWindow.cs new file mode 100644 index 0000000..53d2ca1 --- /dev/null +++ b/src/ARSVIN.Subscriber/KnownInjectionWindow.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using AR.Iec61850.SampledValues.Field; +using ARSVIN.Subscriber.Models; + +namespace ARSVIN.Subscriber; + +public sealed class KnownInjectionWindow : Window +{ + private readonly ComboBox _channel = new(); + private readonly TextBox _expectedRms = new(); + private readonly TextBox _amplitudeTolerance = new(); + private readonly TextBox _expectedAngle = new(); + private readonly TextBox _angleTolerance = new(); + private readonly TextBox _expectedFrequency = new(); + private readonly TextBox _frequencyTolerance = new(); + private readonly TextBlock _measured = new(); + private readonly IReadOnlyList _phasors; + + public KnownInjectionWindow(IReadOnlyList phasors) + { + _phasors = phasors?.Where(item => item.IsValid).ToArray() + ?? throw new ArgumentNullException(nameof(phasors)); + + Title = "Validate Known Injection"; + Width = 520; + Height = 500; + MinWidth = 480; + MinHeight = 460; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ResizeMode = ResizeMode.CanResizeWithGrip; + Content = BuildContent(); + + _channel.ItemsSource = _phasors; + _channel.DisplayMemberPath = nameof(PhasorVector.RmsText); + _channel.SelectedIndex = _phasors.Count > 0 ? 0 : -1; + _channel.SelectionChanged += (_, _) => RefreshMeasured(); + RefreshMeasured(); + } + + public SvKnownInjectionExpectation? Expectation { get; private set; } + public SvKnownInjectionMeasurement? Measurement { get; private set; } + public SvKnownInjectionComparison? Comparison { get; private set; } + + private UIElement BuildContent() + { + var root = new Grid { Margin = new Thickness(18) }; + for (var index = 0; index < 9; index++) + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(185) }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + AddRow(root, 0, "Channel / measured phasor", _channel); + AddRow(root, 1, "Measured", _measured); + AddRow(root, 2, "Expected RMS", _expectedRms); + AddRow(root, 3, "Amplitude tolerance (%)", _amplitudeTolerance); + AddRow(root, 4, "Expected angle (deg)", _expectedAngle); + AddRow(root, 5, "Angle tolerance (deg)", _angleTolerance); + AddRow(root, 6, "Expected frequency (Hz)", _expectedFrequency); + AddRow(root, 7, "Frequency tolerance (Hz)", _frequencyTolerance); + + var note = new TextBlock + { + Text = "A tolerance is optional. Without tolerance, ArSubsv reports REVIEW with numerical error rather than inventing PASS/FAIL. Values use the currently displayed engineering domain and provenance.", + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 12, 0, 12), + Foreground = System.Windows.Media.Brushes.DimGray + }; + Grid.SetRow(note, 8); + Grid.SetColumnSpan(note, 2); + root.Children.Add(note); + + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 12, 0, 0) + }; + var validate = new Button { Content = "Validate", MinWidth = 96, Padding = new Thickness(12, 6, 12, 6), IsDefault = true }; + validate.Click += Validate_Click; + var cancel = new Button { Content = "Cancel", MinWidth = 88, Padding = new Thickness(12, 6, 12, 6), Margin = new Thickness(8, 0, 0, 0), IsCancel = true }; + buttons.Children.Add(validate); + buttons.Children.Add(cancel); + Grid.SetRow(buttons, 10); + Grid.SetColumnSpan(buttons, 2); + root.Children.Add(buttons); + + return root; + } + + private static void AddRow(Grid grid, int row, string label, UIElement editor) + { + var labelBlock = new TextBlock + { + Text = label, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 5, 12, 5) + }; + Grid.SetRow(labelBlock, row); + Grid.SetColumn(labelBlock, 0); + grid.Children.Add(labelBlock); + + if (editor is FrameworkElement element) + element.Margin = new Thickness(0, 5, 0, 5); + Grid.SetRow(editor, row); + Grid.SetColumn(editor, 1); + grid.Children.Add(editor); + } + + private void RefreshMeasured() + { + _measured.Text = _channel.SelectedItem is PhasorVector selected + ? $"{selected.Rms:0.######} {selected.Unit} RMS · {selected.AngleDegrees:0.###}° · {selected.Kind}" + : "No validated phasor is available for this stream."; + } + + private void Validate_Click(object sender, RoutedEventArgs e) + { + if (_channel.SelectedItem is not PhasorVector selected) + { + MessageBox.Show(this, "No valid phasor is available. Resolve SCL mapping, timebase, and waveform confidence first.", Title, MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (!TryParseRequired(_expectedRms.Text, out var expectedRms) || expectedRms < 0) + { + MessageBox.Show(this, "Expected RMS must be a finite non-negative number.", Title, MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (!TryParseOptional(_amplitudeTolerance.Text, out var amplitudeTolerance) || + !TryParseOptional(_expectedAngle.Text, out var expectedAngle) || + !TryParseOptional(_angleTolerance.Text, out var angleTolerance) || + !TryParseOptional(_expectedFrequency.Text, out var expectedFrequency) || + !TryParseOptional(_frequencyTolerance.Text, out var frequencyTolerance)) + { + MessageBox.Show(this, "One or more optional values are not valid numbers.", Title, MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + Expectation = new SvKnownInjectionExpectation + { + Channel = selected.Channel, + ExpectedRms = expectedRms, + Unit = selected.Unit, + Domain = "display", + ExpectedAngleDegrees = expectedAngle, + ExpectedFrequencyHz = expectedFrequency, + AmplitudeTolerancePercent = amplitudeTolerance, + AngleToleranceDegrees = angleTolerance, + FrequencyToleranceHz = frequencyTolerance + }; + Measurement = new SvKnownInjectionMeasurement + { + MeasuredRms = selected.Rms, + MeasuredAngleDegrees = selected.AngleDegrees, + MeasuredFrequencyHz = null + }; + Comparison = SvKnownInjectionComparator.Compare(Expectation, Measurement); + DialogResult = true; + } + + private static bool TryParseRequired(string text, out double value) + { + if (double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value) || + double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out value)) + return double.IsFinite(value); + value = 0; + return false; + } + + private static bool TryParseOptional(string text, out double? value) + { + value = null; + if (string.IsNullOrWhiteSpace(text)) + return true; + if (!TryParseRequired(text, out var parsed)) + return false; + value = parsed; + return true; + } +} From f3d0b75f481e69f2dbac61f40d4e3aec422250c0 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:30:18 +0700 Subject: [PATCH 08/11] Persist known-injection evidence in the selected stream --- .../SvStreamViewModel.KnownInjection.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.KnownInjection.cs diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.KnownInjection.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.KnownInjection.cs new file mode 100644 index 0000000..55629a0 --- /dev/null +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.KnownInjection.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using AR.Iec61850.SampledValues.Field; + +namespace ARSVIN.Subscriber.ViewModels; + +public sealed partial class SvStreamViewModel +{ + public void RecordKnownInjectionEvidence( + SvKnownInjectionExpectation expectation, + SvKnownInjectionMeasurement measurement, + SvKnownInjectionComparison comparison) + { + ArgumentNullException.ThrowIfNull(expectation); + ArgumentNullException.ThrowIfNull(measurement); + ArgumentNullException.ThrowIfNull(comparison); + + var error = comparison.AmplitudeErrorPercent.HasValue + ? $"{comparison.AmplitudeErrorPercent.Value:+0.###;-0.###;0}%" + : comparison.AbsoluteAmplitudeError.ToString("+0.######;-0.######;0", CultureInfo.InvariantCulture); + var evidence = + $"INJECTION · {expectation.Channel} · {comparison.State.ToString().ToUpperInvariant()} · " + + $"expected {expectation.ExpectedRms:0.######} {expectation.Unit} · " + + $"measured {measurement.MeasuredRms:0.######} {expectation.Unit} · error {error}"; + + for (var index = _evidenceDetails.Count - 1; index >= 0; index--) + { + if (_evidenceDetails[index].StartsWith($"INJECTION · {expectation.Channel} ·", StringComparison.Ordinal)) + _evidenceDetails.RemoveAt(index); + } + _evidenceDetails.Insert(0, evidence); + + MeasurementFieldState = comparison.State == SvKnownInjectionResultState.Pass + ? "GOOD" + : comparison.State == SvKnownInjectionResultState.Fail + ? "BAD" + : "WARN"; + MeasurementFieldDetail = evidence; + FieldSummary = $"CAPTURE {CaptureFieldState} · PROTOCOL {ProtocolFieldState} · STREAM {StreamFieldState} · CONFIG {ConfigurationFieldState} · MEAS {MeasurementFieldState}"; + } +} From ee0d64453123d455515c6ac05c5a9d3e2561fdf2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:31:23 +0700 Subject: [PATCH 09/11] Add known-injection validation to the Field Mode toolbar --- .../MainWindow.FieldModeUi.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs index a635e7d..7354b4d 100644 --- a/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs +++ b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs @@ -3,6 +3,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Media; +using AR.Iec61850.SampledValues.Field; namespace ARSVIN.Subscriber; @@ -14,6 +15,7 @@ protected override void OnContentRendered(EventArgs e) { base.OnContentRendered(e); AttachFieldHealthStrip(); + AttachKnownInjectionToolbarButton(); } private void AttachFieldHealthStrip() @@ -54,6 +56,62 @@ private void AttachFieldHealthStrip() ScopeHost.Child = root; } + private void AttachKnownInjectionToolbarButton() + { + var toolbar = FindVisualChildren(this) + .FirstOrDefault(panel => panel.Children.OfType