diff --git a/docs/arsubsv-field-mode-p4.md b/docs/arsubsv-field-mode-p4.md new file mode 100644 index 00000000..67fd2f10 --- /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 603822ea..c1b8d4cc 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 288f089f..71fd8c33 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/Controls/OscilloscopePlot.cs b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs index 61f48ec5..6ab8e8f7 100644 --- a/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs +++ b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs @@ -11,7 +11,7 @@ namespace ARSVIN.Subscriber.Controls; public sealed class OscilloscopePlot : FrameworkElement { private double _cursorFraction = 0.72; - private double _voltageScale = 1.0; + private double _voltageScale = 100.0; private double _currentScale = 1.0; private string _voltageUnit = "count"; private string _currentUnit = "count"; @@ -22,12 +22,22 @@ public sealed class OscilloscopePlot : FrameworkElement nameof(Points), typeof(IEnumerable), typeof(OscilloscopePlot), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnPointsChanged)); + public static readonly DependencyProperty StatusMessageProperty = DependencyProperty.Register( + nameof(StatusMessage), typeof(string), typeof(OscilloscopePlot), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender)); + public IEnumerable? Points { get => (IEnumerable?)GetValue(PointsProperty); set => SetValue(PointsProperty, value); } + public string StatusMessage + { + get => (string)GetValue(StatusMessageProperty); + set => SetValue(StatusMessageProperty, value); + } + public OscilloscopePlot() { Focusable = true; @@ -37,8 +47,8 @@ public OscilloscopePlot() protected override Size MeasureOverride(Size availableSize) { var width = double.IsInfinity(availableSize.Width) ? 840 : availableSize.Width; - var height = double.IsInfinity(availableSize.Height) ? 560 : availableSize.Height; - return new Size(Math.Max(520, width), Math.Max(360, height)); + var height = double.IsInfinity(availableSize.Height) ? 520 : availableSize.Height; + return new Size(Math.Max(480, width), Math.Max(320, height)); } protected override void OnMouseDown(MouseButtonEventArgs e) @@ -72,7 +82,14 @@ protected override void OnRender(DrawingContext dc) var points = Points?.OfType().ToArray() ?? Array.Empty(); if (points.Length < 2) { - DrawCenteredText(dc, bounds, "Waiting for decoded SV samples", 13, "#64748B"); + var message = string.IsNullOrWhiteSpace(StatusMessage) + ? "Waiting for a trusted contiguous SV window" + : StatusMessage; + var color = message.Contains("withheld", StringComparison.OrdinalIgnoreCase) || + message.Contains("degraded", StringComparison.OrdinalIgnoreCase) + ? "#B45309" + : "#64748B"; + DrawCenteredWrappedText(dc, bounds, message, 12.5, color); return; } @@ -81,26 +98,26 @@ protected override void OnRender(DrawingContext dc) const double pad = 10; const double gap = 12; - var laneHeight = Math.Max(130, (bounds.Height - (pad * 2) - gap) / 2.0); + var laneHeight = Math.Max(125, (bounds.Height - (pad * 2) - gap) / 2.0); var voltageRect = new Rect(pad, pad, bounds.Width - pad * 2, laneHeight); var currentRect = new Rect(pad, pad + laneHeight + gap, bounds.Width - pad * 2, laneHeight); DrawLane(dc, voltageRect, "Voltage", latest.VoltageUnit, points, new[] { - new Trace("Va", p => p.Va, "#EF4444"), - new Trace("Vb", p => p.Vb, "#D97706"), - new Trace("Vc", p => p.Vc, "#2563EB"), - new Trace("Vn", p => p.Vn, "#94A3B8") + new Trace("Va", point => point.Va, "#EF4444"), + new Trace("Vb", point => point.Vb, "#D97706"), + new Trace("Vc", point => point.Vc, "#2563EB"), + new Trace("Vn", point => point.Vn, "#94A3B8") }, ref _voltageScale); DrawLane(dc, currentRect, "Current", latest.CurrentUnit, points, new[] { - new Trace("Ia", p => p.Ia, "#EF4444"), - new Trace("Ib", p => p.Ib, "#D97706"), - new Trace("Ic", p => p.Ic, "#2563EB"), - new Trace("In", p => p.In, "#94A3B8") + new Trace("Ia", point => point.Ia, "#EF4444"), + new Trace("Ib", point => point.Ib, "#D97706"), + new Trace("Ic", point => point.Ic, "#2563EB"), + new Trace("In", point => point.In, "#94A3B8") }, ref _currentScale); } @@ -119,22 +136,23 @@ private void DrawLane( var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36)); var absoluteValues = traces .SelectMany(trace => points.Select(trace.Selector)) - .Where(value => value.HasValue) + .Where(value => value.HasValue && double.IsFinite(value.Value)) .Select(value => Math.Abs(value!.Value)) + .OrderBy(value => value) .ToArray(); - var observedMax = absoluteValues.DefaultIfEmpty(0).Max(); + var observedRobust = Percentile(absoluteValues, 0.99); var floor = ResolveScaleFloor(title, unit); - var targetScale = Math.Max(floor, observedMax * 1.15); + var targetScale = Math.Max(floor, observedRobust * 1.15); retainedScale = targetScale >= retainedScale ? targetScale - : Math.Max(targetScale, retainedScale * 0.92); + : Math.Max(targetScale, retainedScale * 0.985); - var nearZero = observedMax <= floor * 0.2; + var nearZero = observedRobust <= floor * 0.1; DrawText(dc, title, new Point(lane.Left + 12, lane.Top + 10), 12.5, "#2563EB", FontWeights.SemiBold); DrawText(dc, unit, new Point(lane.Left + 12, lane.Bottom - 28), 11, "#64748B", FontWeights.Normal); DrawText(dc, $"±{retainedScale:0.###}", new Point(lane.Left + 12, lane.Top + 31), 10.5, "#94A3B8", FontWeights.Normal); if (nearZero) - DrawText(dc, "near zero", new Point(lane.Left + 12, lane.Top + 48), 9.8, "#94A3B8", FontWeights.Normal); + DrawText(dc, "noise floor", new Point(lane.Left + 12, lane.Top + 48), 9.8, "#94A3B8", FontWeights.Normal); foreach (var trace in traces) DrawTrace(dc, plot, points, trace, retainedScale); @@ -160,7 +178,6 @@ private void ResetScaleWhenUnitChanges(string voltageUnit, string currentUnit) _voltageUnit = voltageUnit; _voltageScale = ResolveScaleFloor("Voltage", voltageUnit); } - if (!string.Equals(_currentUnit, currentUnit, StringComparison.Ordinal)) { _currentUnit = currentUnit; @@ -171,10 +188,18 @@ private void ResetScaleWhenUnitChanges(string voltageUnit, string currentUnit) private static double ResolveScaleFloor(string title, string unit) { if (unit.Equals("V", StringComparison.OrdinalIgnoreCase)) - return 0.5; + return 100.0; if (unit.Equals("A", StringComparison.OrdinalIgnoreCase)) - return 0.02; - return title.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? 100.0 : 10.0; + return 1.0; + return title.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? 1000.0 : 100.0; + } + + private static double Percentile(IReadOnlyList ordered, double percentile) + { + if (ordered.Count == 0) + return 0; + var index = Math.Clamp((int)Math.Ceiling((ordered.Count - 1) * percentile), 0, ordered.Count - 1); + return ordered[index]; } private static void DrawGrid(DrawingContext dc, Rect lane) @@ -182,16 +207,15 @@ private static void DrawGrid(DrawingContext dc, Rect lane) var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36)); var minor = Pen("#E8EEF7", 0.85, 2, 6); var major = Pen("#D6E0EC", 0.95, 4, 6); - for (var i = 0; i <= 8; i++) + for (var index = 0; index <= 8; index++) { - var x = plot.Left + plot.Width * i / 8.0; - dc.DrawLine(i % 2 == 0 ? major : minor, new Point(x, plot.Top), new Point(x, plot.Bottom)); + var x = plot.Left + plot.Width * index / 8.0; + dc.DrawLine(index % 2 == 0 ? major : minor, new Point(x, plot.Top), new Point(x, plot.Bottom)); } - - for (var i = 0; i <= 4; i++) + for (var index = 0; index <= 4; index++) { - var y = plot.Top + plot.Height * i / 4.0; - dc.DrawLine(i == 2 ? Pen("#B6C6D8", 1.05) : minor, new Point(plot.Left, y), new Point(plot.Right, y)); + var y = plot.Top + plot.Height * index / 4.0; + dc.DrawLine(index == 2 ? Pen("#B6C6D8", 1.05) : minor, new Point(plot.Left, y), new Point(plot.Right, y)); } } @@ -204,7 +228,7 @@ private static void DrawTrace( { var usable = points .Select((point, index) => (value: trace.Selector(point), index)) - .Where(item => item.value.HasValue) + .Where(item => item.value.HasValue && double.IsFinite(item.value.Value)) .ToArray(); if (usable.Length < 2 || scale <= 0) return; @@ -212,21 +236,20 @@ private static void DrawTrace( var geometry = new StreamGeometry(); using (var context = geometry.Open()) { - for (var i = 0; i < usable.Length; i++) + for (var index = 0; index < usable.Length; index++) { - var sourceIndex = usable[i].index; + var sourceIndex = usable[index].index; var x = plot.Left + plot.Width * sourceIndex / Math.Max(1, points.Count - 1); - var y = plot.Top + plot.Height / 2.0 - (usable[i].value!.Value / scale) * plot.Height * 0.46; + var y = plot.Top + plot.Height / 2.0 - (usable[index].value!.Value / scale) * plot.Height * 0.46; y = Math.Clamp(y, plot.Top + 1, plot.Bottom - 1); - if (i == 0) + if (index == 0) context.BeginFigure(new Point(x, y), false, false); else context.LineTo(new Point(x, y), true, false); } } - geometry.Freeze(); - dc.DrawGeometry(null, Pen(trace.Color, 1.8), geometry); + dc.DrawGeometry(null, Pen(trace.Color, 1.65), geometry); } private void UpdateCursor(double x) @@ -245,25 +268,24 @@ private void AttachNotifier(INotifyCollectionChanged? oldValue, INotifyCollectio { if (oldValue is not null) oldValue.CollectionChanged -= OnPointsCollectionChanged; - if (_pointsNotifier is not null && - !ReferenceEquals(_pointsNotifier, oldValue) && - !ReferenceEquals(_pointsNotifier, newValue)) + if (_pointsNotifier is not null && !ReferenceEquals(_pointsNotifier, oldValue) && !ReferenceEquals(_pointsNotifier, newValue)) _pointsNotifier.CollectionChanged -= OnPointsCollectionChanged; _pointsNotifier = newValue; if (_pointsNotifier is not null) _pointsNotifier.CollectionChanged += OnPointsCollectionChanged; - InvalidateVisual(); } private void OnPointsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => Dispatcher.InvokeAsync(InvalidateVisual); - private static void DrawCenteredText(DrawingContext dc, Rect rect, string text, double size, string color) + private static void DrawCenteredWrappedText(DrawingContext dc, Rect rect, string text, double size, string color) { var formatted = MakeText(text, size, color, FontWeights.SemiBold); - dc.DrawText(formatted, new Point(rect.Left + (rect.Width - formatted.Width) / 2, rect.Top + (rect.Height - formatted.Height) / 2)); + formatted.MaxTextWidth = Math.Max(120, rect.Width - 80); + formatted.TextAlignment = TextAlignment.Center; + dc.DrawText(formatted, new Point(rect.Left + 40, rect.Top + (rect.Height - formatted.Height) / 2)); } private static void DrawText(DrawingContext dc, string text, Point point, double size, string color, FontWeight weight) @@ -291,4 +313,4 @@ private static Pen Pen(string color, double width, double dash = 0, double gap = } private sealed record Trace(string Name, Func Selector, string Color); -} +} \ No newline at end of file diff --git a/src/ARSVIN.Subscriber/Controls/PhasorPlot.cs b/src/ARSVIN.Subscriber/Controls/PhasorPlot.cs index eeaa8896..97a56c0f 100644 --- a/src/ARSVIN.Subscriber/Controls/PhasorPlot.cs +++ b/src/ARSVIN.Subscriber/Controls/PhasorPlot.cs @@ -15,12 +15,22 @@ public sealed class PhasorPlot : FrameworkElement nameof(Vectors), typeof(IEnumerable), typeof(PhasorPlot), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnVectorsChanged)); + public static readonly DependencyProperty EmptyMessageProperty = DependencyProperty.Register( + nameof(EmptyMessage), typeof(string), typeof(PhasorPlot), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender)); + public IEnumerable? Vectors { get => (IEnumerable?)GetValue(VectorsProperty); set => SetValue(VectorsProperty, value); } + public string EmptyMessage + { + get => (string)GetValue(EmptyMessageProperty); + set => SetValue(EmptyMessageProperty, value); + } + protected override Size MeasureOverride(Size availableSize) { var width = double.IsInfinity(availableSize.Width) ? 420 : availableSize.Width; @@ -36,7 +46,9 @@ protected override void OnRender(DrawingContext dc) return; dc.DrawRoundedRectangle(Brush("#FBFDFF"), Pen("#D8E2EF", 1), rect, 10, 10); - var vectors = Vectors?.OfType().Where(x => x.Rms > 0).ToArray() ?? Array.Empty(); + var vectors = Vectors?.OfType() + .Where(vector => vector.IsValid && vector.Rms > 0) + .ToArray() ?? Array.Empty(); var plot = new Rect(rect.Left + 12, rect.Top + 12, rect.Width - 24, rect.Height - 24); var center = new Point(plot.Left + plot.Width / 2.0, plot.Top + plot.Height / 2.0 + 6); var radius = Math.Max(40, Math.Min(plot.Width, plot.Height) / 2.0 - 34); @@ -46,16 +58,22 @@ protected override void OnRender(DrawingContext dc) if (vectors.Length == 0) { - DrawCenteredText(dc, rect, "Waiting for decoded samples", 13, "#64748B"); + var message = string.IsNullOrWhiteSpace(EmptyMessage) + ? "Waiting for a trusted contiguous cycle" + : EmptyMessage; + var color = message.Contains("withheld", StringComparison.OrdinalIgnoreCase) + ? "#B45309" + : "#64748B"; + DrawCenteredWrappedText(dc, rect, message, 11.8, color); return; } - var voltageMax = vectors.Where(x => x.Kind.Equals("Voltage", StringComparison.OrdinalIgnoreCase)).Select(x => x.Rms).DefaultIfEmpty(0).Max(); - var currentMax = vectors.Where(x => x.Kind.Equals("Current", StringComparison.OrdinalIgnoreCase)).Select(x => x.Rms).DefaultIfEmpty(0).Max(); - if (voltageMax <= 0) voltageMax = vectors.Select(x => x.Rms).DefaultIfEmpty(1).Max(); - if (currentMax <= 0) currentMax = vectors.Select(x => x.Rms).DefaultIfEmpty(1).Max(); + var voltageMax = vectors.Where(vector => vector.Kind.Equals("Voltage", StringComparison.OrdinalIgnoreCase)).Select(vector => vector.Rms).DefaultIfEmpty(0).Max(); + var currentMax = vectors.Where(vector => vector.Kind.Equals("Current", StringComparison.OrdinalIgnoreCase)).Select(vector => vector.Rms).DefaultIfEmpty(0).Max(); + if (voltageMax <= 0) voltageMax = vectors.Select(vector => vector.Rms).DefaultIfEmpty(1).Max(); + if (currentMax <= 0) currentMax = vectors.Select(vector => vector.Rms).DefaultIfEmpty(1).Max(); - foreach (var vector in vectors.OrderBy(x => x.Channel.StartsWith('V') ? 0 : 1).ThenBy(x => x.Channel)) + foreach (var vector in vectors.OrderBy(vector => vector.Channel.StartsWith('V') ? 0 : 1).ThenBy(vector => vector.Channel)) DrawVector(dc, center, radius, vector, voltageMax, currentMax); } @@ -68,9 +86,9 @@ private static void DrawGrid(DrawingContext dc, Point center, double radius) dc.DrawLine(Pen("#B7C6D8", 1.1), new Point(center.X - radius, center.Y), new Point(center.X + radius, center.Y)); dc.DrawLine(Pen("#B7C6D8", 1.1), new Point(center.X, center.Y - radius), new Point(center.X, center.Y + radius)); var diagonal = radius / Math.Sqrt(2.0); - var diagPen = Pen("#E2E8F0", 0.9, 3, 5); - dc.DrawLine(diagPen, new Point(center.X - diagonal, center.Y - diagonal), new Point(center.X + diagonal, center.Y + diagonal)); - dc.DrawLine(diagPen, new Point(center.X + diagonal, center.Y - diagonal), new Point(center.X - diagonal, center.Y + diagonal)); + var diagonalPen = Pen("#E2E8F0", 0.9, 3, 5); + dc.DrawLine(diagonalPen, new Point(center.X - diagonal, center.Y - diagonal), new Point(center.X + diagonal, center.Y + diagonal)); + dc.DrawLine(diagonalPen, new Point(center.X + diagonal, center.Y - diagonal), new Point(center.X - diagonal, center.Y + diagonal)); dc.DrawEllipse(Brush("#2563EB"), null, center, 4.5, 4.5); DrawText(dc, "0°", new Point(center.X + radius + 6, center.Y - 10), 10, "#64748B", FontWeights.Normal); DrawText(dc, "+90°", new Point(center.X - 16, center.Y - radius - 20), 10, "#64748B", FontWeights.Normal); @@ -105,16 +123,15 @@ private static void DrawArrowHead(DrawingContext dc, Point start, Point end, str vector.Normalize(); var normal = new Vector(-vector.Y, vector.X); - var p1 = end + (vector * 10) + (normal * 4); - var p2 = end + (vector * 10) - (normal * 4); + var first = end + (vector * 10) + (normal * 4); + var second = end + (vector * 10) - (normal * 4); var geometry = new StreamGeometry(); using (var context = geometry.Open()) { context.BeginFigure(end, true, true); - context.LineTo(p1, true, false); - context.LineTo(p2, true, false); + context.LineTo(first, true, false); + context.LineTo(second, true, false); } - geometry.Freeze(); dc.DrawGeometry(Brush(color), null, geometry); } @@ -148,17 +165,18 @@ private void AttachNotifier(INotifyCollectionChanged? oldValue, INotifyCollectio _vectorsNotifier = newValue; if (_vectorsNotifier is not null) _vectorsNotifier.CollectionChanged += OnVectorsCollectionChanged; - InvalidateVisual(); } private void OnVectorsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => Dispatcher.InvokeAsync(InvalidateVisual); - private static void DrawCenteredText(DrawingContext dc, Rect rect, string text, double size, string color) + private static void DrawCenteredWrappedText(DrawingContext dc, Rect rect, string text, double size, string color) { - var ft = MakeText(text, size, color, FontWeights.SemiBold); - dc.DrawText(ft, new Point(rect.Left + (rect.Width - ft.Width) / 2, rect.Top + (rect.Height - ft.Height) / 2)); + var formatted = MakeText(text, size, color, FontWeights.SemiBold); + formatted.MaxTextWidth = Math.Max(120, rect.Width - 64); + formatted.TextAlignment = TextAlignment.Center; + dc.DrawText(formatted, new Point(rect.Left + 32, rect.Top + (rect.Height - formatted.Height) / 2)); } private static void DrawText(DrawingContext dc, string text, Point point, double size, string color, FontWeight weight) @@ -169,9 +187,9 @@ private static FormattedText MakeText(string text, double size, string color, Fo new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal), size, Brush(color), 1.0); - private static Brush Brush(string color, double opacity = 1.0) + private static Brush Brush(string color) { - var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)) { Opacity = opacity }; + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)); brush.Freeze(); return brush; } @@ -184,4 +202,4 @@ private static Pen Pen(string color, double width, double dash = 0, double gap = pen.Freeze(); return pen; } -} +} \ No newline at end of file diff --git a/src/ARSVIN.Subscriber/KnownInjectionWindow.cs b/src/ARSVIN.Subscriber/KnownInjectionWindow.cs new file mode 100644 index 00000000..53d2ca14 --- /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 00000000..95bbf43a --- /dev/null +++ b/src/ARSVIN.Subscriber/MainWindow.FieldModeUi.cs @@ -0,0 +1,58 @@ +using System.Windows; +using AR.Iec61850.SampledValues.Field; + +namespace ARSVIN.Subscriber; + +public partial class MainWindow +{ + private void KnownInjection_Click(object sender, RoutedEventArgs e) + { + var selected = _viewModel.SelectedStream; + if (selected is null) + { + MessageBox.Show(this, "Select an SV stream before validating a known injection.", "Known Injection", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (!selected.IsAnalysisTrusted) + { + MessageBox.Show( + this, + $"Waveform and phasor analysis is not trusted yet.\n\n{selected.AnalysisTrustDetail}", + "Known Injection", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + var phasors = selected.GenericPhasors + .Where(item => item.IsValid && !string.Equals(item.Unit, "count", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (phasors.Length == 0) + { + MessageBox.Show( + this, + "No engineering phasor is available. Import the matching SCL/CID and resolve engineering scaling first.", + "Known Injection", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var dialog = new KnownInjectionWindow(phasors) { Owner = this }; + if (dialog.ShowDialog() != true || dialog.Expectation is null || dialog.Measurement is null || dialog.Comparison is null) + return; + + selected.RecordKnownInjectionEvidence(dialog.Expectation, dialog.Measurement, dialog.Comparison); + var comparison = dialog.Comparison; + var error = comparison.AmplitudeErrorPercent.HasValue + ? $"{comparison.AmplitudeErrorPercent.Value:+0.###;-0.###;0}%" + : $"{comparison.AbsoluteAmplitudeError:+0.######;-0.######;0}"; + MessageBox.Show( + this, + $"Result: {comparison.State.ToString().ToUpperInvariant()}\nAmplitude error: {error}\n\nThe result is stored in selected-stream evidence and included in the support bundle.", + "Known Injection", + MessageBoxButton.OK, + comparison.State == SvKnownInjectionResultState.Fail ? MessageBoxImage.Warning : MessageBoxImage.Information); + } +} \ No newline at end of file diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml index b90e6872..d97e1898 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml +++ b/src/ARSVIN.Subscriber/MainWindow.xaml @@ -5,10 +5,10 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Title="ArSubsv - Sampled Values Subscriber" - Width="1460" - Height="860" - MinWidth="1220" - MinHeight="720" + Width="1500" + Height="900" + MinWidth="1280" + MinHeight="760" Background="{StaticResource ShellBg}" SnapsToDevicePixels="True" UseLayoutRounding="True" @@ -18,10 +18,14 @@ + + + + + + + + + + - + - + - - - - - - - - - + + + - - + - + - - - - - - - - - -