diff --git a/docs/arsubsv-field-mode-p4.md b/docs/arsubsv-field-mode-p4.md new file mode 100644 index 0000000..67fd2f1 --- /dev/null +++ b/docs/arsubsv-field-mode-p4.md @@ -0,0 +1,47 @@ +# ArSubsv Field Mode P4 + +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: + +- `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/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 288f089..71fd8c3 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -37,7 +37,8 @@ if ($NoRestore) { } # ARSVIN tests execute against the pinned sibling ARIEC61850 source of truth. -# The engine repository owns its full-suite percentage gate. This integration gate checks: +# The engine repository owns its full-suite percentage gate, including the P4 Field layer. +# This application integration gate checks: # 1. the application-consumed protocol core remains strongly covered; and # 2. the absolute amount of exercised production code does not regress when the engine grows. $arguments += @( @@ -99,6 +100,10 @@ function Test-IsProtocolCoreFile { if ($path.Contains('/AR.Iec61850/Asn1/')) { return $true } if ($path.Contains('/AR.Iec61850/Ethernet/')) { return $true } if ($path.Contains('/AR.Iec61850/Transports/')) { return $true } + + # P4 Field contracts are tested by ARIEC61850's own deterministic suite. Excluding them here + # prevents application coverage from being diluted merely because the shared engine grows. + if ($path.Contains('/AR.Iec61850/SampledValues/Field/')) { return $false } if ($path.Contains('/AR.Iec61850/SampledValues/')) { return $true } if ($path.Contains('/AR.Iec61850/Capture/')) { @@ -145,9 +150,9 @@ Write-Host "ARIEC61850 instrumented lines: $overallLinesValid" Write-Host "ARIEC61850 covered lines exercised by ARSVIN: $overallLinesCovered" Write-Host "Minimum covered production lines: $MinimumWholeEngineCoveredLines" Write-Host "Informational whole-engine line coverage: $overallLineCoverage%" -Write-Host "Protocol core files: $($coreFiles.Count)" -Write-Host "Protocol core lines: $coreLinesValid" -Write-Host "Protocol core covered lines: $coreLinesCovered" +Write-Host "Application-consumed protocol core files: $($coreFiles.Count)" +Write-Host "Application-consumed protocol core lines: $coreLinesValid" +Write-Host "Application-consumed protocol core covered lines: $coreLinesCovered" Write-Host "Minimum protocol-core covered lines: $MinimumProtocolCoreCoveredLines" Write-Host "Protocol core line coverage: $coreLineCoverage%" Write-Host "Protocol core minimum percentage: $MinimumLineCoverage%" @@ -157,7 +162,7 @@ if ($env:GITHUB_STEP_SUMMARY) { @" ## ARSVIN integration coverage against pinned ARIEC61850 -The ARIEC61850 repository owns the full-engine percentage gate. This paired gate measures the reusable code actually exercised by ARSVIN Publisher and ArSubsv. +ARIEC61850 owns the full-engine and P4 Field-layer test gates. This paired gate measures the reusable protocol core exercised directly by ARSVIN Publisher and ArSubsv. | Metric | Result | |---|---:| @@ -165,7 +170,7 @@ The ARIEC61850 repository owns the full-engine percentage gate. This paired gate | Production lines exercised by ARSVIN | **$overallLinesCovered** | | Minimum exercised production lines | **$MinimumWholeEngineCoveredLines** | | Informational whole-engine line coverage | **$overallLineCoverage%** | -| Tested protocol-core files | **$($coreFiles.Count)** | +| Tested application-consumed protocol-core files | **$($coreFiles.Count)** | | Protocol-core instrumented lines | **$coreLinesValid** | | Protocol-core covered lines | **$coreLinesCovered** | | Minimum protocol-core covered lines | **$MinimumProtocolCoreCoveredLines** | 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; + } +} diff --git a/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs new file mode 100644 index 0000000..7354b4d --- /dev/null +++ b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs @@ -0,0 +1,184 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; +using AR.Iec61850.SampledValues.Field; + +namespace ARSVIN.Subscriber; + +public partial class MainWindow +{ + private bool _fieldModeUiAttached; + + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + AttachFieldHealthStrip(); + AttachKnownInjectionToolbarButton(); + } + + 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 void AttachKnownInjectionToolbarButton() + { + var toolbar = FindVisualChildren(this) + .FirstOrDefault(panel => panel.Children.OfType