Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9232e37
P4B: add ArSubsv field mode and support-bundle workflow
masarray Jul 28, 2026
6d3d5c3
Record the rebased Field Mode test boundary
masarray Jul 28, 2026
4ae2a52
Show five field-health axes above the waveform
masarray Jul 28, 2026
063f283
Initialize Field Mode from the existing stream constructor
masarray Jul 28, 2026
e8bab1b
Initialize generic and field presentations from one constructor
masarray Jul 28, 2026
37b4bfb
Keep engine-only regression tests independent of the WPF application
masarray Jul 28, 2026
a47a025
Add a practical known-injection validation dialog
masarray Jul 28, 2026
f3d0b75
Persist known-injection evidence in the selected stream
masarray Jul 28, 2026
ee0d644
Add known-injection validation to the Field Mode toolbar
masarray Jul 28, 2026
ae9d2d8
Keep application coverage focused on the consumed protocol core
masarray Jul 28, 2026
f6f0b1e
Ignore unrelated Ethernet traffic during offline SV replay
masarray Jul 28, 2026
5ae6dd1
Stabilize SV analysis and refactor Field Mode layout
masarray Aug 3, 2026
c5ba82a
Stabilize waveform and phasor presentation on contiguous samples
masarray Aug 3, 2026
c33d9cb
Remove redundant presentation stabilizer after integrated trust pipeline
masarray Aug 3, 2026
2027abe
Fix nullable cadence calculations in Field Mode
masarray Aug 3, 2026
34c3a91
Show contiguous sample-domain waveform despite host timing warnings
masarray Aug 3, 2026
e2f5a09
Separate host timing warnings from sample-domain measurement display
masarray Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/arsubsv-field-mode-p4.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
@@ -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."
}
17 changes: 11 additions & 6 deletions scripts/test-with-coverage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 += @(
Expand Down Expand Up @@ -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/')) {
Expand Down Expand Up @@ -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%"
Expand All @@ -157,15 +162,15 @@ 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 |
|---|---:|
| Whole `AR.Iec61850` instrumented lines | **$overallLinesValid** |
| 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** |
Expand Down
110 changes: 66 additions & 44 deletions src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -72,7 +82,14 @@ protected override void OnRender(DrawingContext dc)
var points = Points?.OfType<WaveformPoint>().ToArray() ?? Array.Empty<WaveformPoint>();
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;
}

Expand All @@ -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);
}

Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -171,27 +188,34 @@ 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<double> 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)
{
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));
}
}

Expand All @@ -204,29 +228,28 @@ 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;

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)
Expand All @@ -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)
Expand Down Expand Up @@ -291,4 +313,4 @@ private static Pen Pen(string color, double width, double dash = 0, double gap =
}

private sealed record Trace(string Name, Func<WaveformPoint, double?> Selector, string Color);
}
}
Loading
Loading