Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:

- name: Test with whole-engine and protocol-core coverage gates
shell: pwsh
run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 -NoRestore
run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore

- name: Upload test and coverage evidence
if: always()
Expand All @@ -103,4 +103,4 @@ jobs:
run: dotnet list src/ARSVIN/ARSVIN.csproj package --vulnerable --include-transitive

- name: Subscriber dependency vulnerability report
run: dotnet list src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj package --vulnerable --include-transitive
run: dotnet list src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj package --vulnerable --include-transitive
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change
- Added strict and compatible configuration-versus-wire comparison findings without blocking capture or decoding.
- Added deterministic tests for observation windows, profile detection, evidence explanations, configuration mismatch handling, sparse-evidence confidence, and profile-definition provenance.
- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.
- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.
- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.
- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.

### Changed

Expand All @@ -29,9 +32,16 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change
- Public engineering workflow targets are described generically without proprietary product comparisons or branding.
- The built-in profile catalog contains only the generic SCL-driven Layer-2 SV fallback until profile-specific requirements are verified.
- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.
- Expanded the deterministic suite from 54 to 74 tests.
- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.
- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.
- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.
- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.
- Expanded the deterministic suite from 54 to 88 tests.
- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.
- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.

### Fixed

- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.
- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.

## 0.4.0 — 2026-07-12

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,10 @@ Build release artifacts except the installer:
Run tests with both coverage gates:

```powershell
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5
```

The current baseline contains 74 deterministic tests. CI enforces the documented whole-engine and protocol-core coverage floors; current values are engineering regression evidence, not formal protocol conformance evidence.
The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.

## Repository architecture

Expand Down
4 changes: 2 additions & 2 deletions docs/build-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ Publisher, Subscriber, and Tests reference the same `ARSVIN.Engine` assembly. Pr
## Coverage evidence

```powershell
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5
```

The coverage workflow retains TRX, full test logs, and Cobertura output under `artifacts/test-results`, then enforces whole-engine and protocol-core regression floors.

The documented baseline contains 74 deterministic tests, 13.35% whole-engine line coverage, and 70.47% protocol-core line coverage. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.
The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.

## Build and validate the public site

Expand Down
38 changes: 24 additions & 14 deletions docs/sv-profile-infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,17 @@ ARSVIN separates observed wire facts, configured expectations, and profile claim
## Data flow

```text
Parsed live or PCAP frame
SvFrameObservation
SvObservationAccumulator
SvObservedStreamFacts
├── SvProfileDetector
└── SvConfigurationComparer
Live Npcap frame ─┐
├── SvStreamObservationManager
Imported PCAP ────┘ ↓
per-stream bounded window
SvObservedStreamFacts
├── SvProfileDetector
└── SvConfigurationComparer
```

The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.
The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.

## Observed facts

Expand Down Expand Up @@ -85,10 +84,21 @@ Two modes are available:

Neither mode stops receive-side capture or decoding. Unknown and conflicting streams remain visible.

## Current integration boundary

`SvStreamObservationManager` now:

- accepts parsed frames from live Npcap capture and classic-PCAP replay;
- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;
- deliberately keeps `confRev` outside the key so revision changes remain observable;
- retains input provenance (`LiveCapture` or `PcapReplay`);
- adds SCL-derived dataset signatures when a stream is bound; and
- exposes immutable rolling facts to the Subscriber runtime snapshot.

## Next integration

1. Feed parsed live and PCAP frames into `SvObservationAccumulator`.
2. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.
3. Present one compact profile/confidence state per observed stream.
4. Show configuration mismatch details on demand rather than adding permanent visual noise.
1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.
2. Run strict or compatible configuration comparison per observed stream.
3. Present one compact profile/confidence and SCL-match state per selected stream.
4. Show evidence and mismatch details on demand rather than adding permanent visual noise.
5. Add profile-specific definitions only after source review and deterministic evidence.
4 changes: 2 additions & 2 deletions scripts/test-with-coverage.ps1
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[CmdletBinding()]
param(
[ValidateRange(0, 100)]
[double] $MinimumLineCoverage = 70,
[double] $MinimumLineCoverage = 72.5,

[ValidateRange(0, 100)]
[double] $MinimumWholeEngineLineCoverage = 13,
[double] $MinimumWholeEngineLineCoverage = 14.25,

[switch] $NoRestore
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
using System.Collections.Concurrent;
using AR.Iec61850.Scl;

namespace AR.Iec61850.SampledValues.Profiles;

public enum SvObservationInputKind
{
Unknown,
LiveCapture,
PcapReplay
}

public sealed record SvObservedStreamKey
{
public string SourceMac { get; init; } = string.Empty;
public string DestinationMac { get; init; } = string.Empty;
public ushort? VlanId { get; init; }
public ushort AppId { get; init; }
public string SvId { get; init; } = string.Empty;
public string DataSetReference { get; init; } = string.Empty;

public string Id
{
get
{
var vlan = VlanId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
return $"SV|{AppId:X4}|{SourceMac}|{DestinationMac}|{vlan}|{SvId}|{DataSetReference}";
}
}

public static SvObservedStreamKey FromFrame(SampledValuesFrame frame)
{
ArgumentNullException.ThrowIfNull(frame);
var first = frame.Pdu.Asdus.FirstOrDefault()
?? throw new ArgumentException("An observed SV frame requires at least one ASDU.", nameof(frame));

return new SvObservedStreamKey
{
SourceMac = frame.Source.ToString(),
DestinationMac = frame.Destination.ToString(),
VlanId = frame.Vlan?.VlanId,
AppId = frame.AppId,
SvId = first.SvId,
DataSetReference = first.DataSetReference
};
}
}

public sealed record SvStreamObservationSnapshot
{
public SvObservedStreamKey Key { get; init; } = new();
public SvObservedStreamFacts Facts { get; init; } = new();
public IReadOnlyList<SvObservationInputKind> InputKinds { get; init; }
= Array.Empty<SvObservationInputKind>();
public SvObservationInputKind LastInputKind { get; init; }
public bool IsBoundToScl { get; init; }
public string ControlBlockReference { get; init; } = string.Empty;
public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>();
}

public sealed class SvStreamObservationManager
{
private sealed class StreamState
{
private readonly object _gate = new();
private readonly HashSet<SvObservationInputKind> _inputKinds = [];
private readonly Queue<string> _diagnostics = new();

public StreamState(int maximumObservations, TimeSpan maximumAge)
{
Accumulator = new SvObservationAccumulator(maximumObservations, maximumAge);
}

public SvObservationAccumulator Accumulator { get; }
public SvObservationInputKind LastInputKind { get; private set; }
public bool IsBoundToScl { get; private set; }
public string ControlBlockReference { get; private set; } = string.Empty;

public void Add(
SvFrameObservation observation,
SvObservationInputKind inputKind,
SampledValuesPublisherProfile? profile,
IEnumerable<string> diagnostics)
{
Accumulator.Add(observation);
lock (_gate)
{
LastInputKind = inputKind;
_inputKinds.Add(inputKind);
if (profile is not null)
{
IsBoundToScl = true;
ControlBlockReference = profile.Stream.ControlBlockReference;
}

foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item)))
{
if (_diagnostics.Contains(diagnostic, StringComparer.Ordinal))
continue;

_diagnostics.Enqueue(diagnostic);
while (_diagnostics.Count > 16)
_diagnostics.Dequeue();
}
}
}

public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key)
{
var facts = Accumulator.BuildFacts();
lock (_gate)
{
return new SvStreamObservationSnapshot
{
Key = key,
Facts = facts,
InputKinds = _inputKinds.OrderBy(item => item).ToArray(),
LastInputKind = LastInputKind,
IsBoundToScl = IsBoundToScl,
ControlBlockReference = ControlBlockReference,
Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray()
};
}
}
}

private readonly ConcurrentDictionary<SvObservedStreamKey, StreamState> _streams = new();
private readonly int _maximumObservations;
private readonly TimeSpan _maximumAge;

public SvStreamObservationManager(
int maximumObservations = SvObservationAccumulator.DefaultMaximumObservations,
TimeSpan? maximumAge = null)
{
if (maximumObservations < 2)
throw new ArgumentOutOfRangeException(nameof(maximumObservations));

_maximumAge = maximumAge ?? SvObservationAccumulator.DefaultMaximumAge;
if (_maximumAge <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(maximumAge));

_maximumObservations = maximumObservations;
}

public int Count => _streams.Count;

public bool TryObserve(
DateTimeOffset timestamp,
SampledValuesFrame frame,
SvObservationInputKind inputKind,
SampledValuesPublisherProfile? profile,
out SvStreamObservationSnapshot snapshot,
double? nominalFrequencyHz = null)
{
ArgumentNullException.ThrowIfNull(frame);
snapshot = new SvStreamObservationSnapshot();

var asdus = frame.Pdu.Asdus;
var first = asdus.FirstOrDefault();
if (first is null || first.SamplePayload.Length <= 0)
return false;

var key = SvObservedStreamKey.FromFrame(frame);
var diagnostics = ValidateFrameConsistency(asdus);
var signature = profile?.Entries.Select(ToSignature).ToArray()
?? Array.Empty<SvDatasetElementSignature>();
Comment on lines +165 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate SCL identity before publishing derived facts

When the Subscriber's FindProfile returns its APPID-only fallback for a frame whose dataset reference does not match that SCL stream, this code still converts the profile entries into the observation's DataSetSignature and the state is marked SCL-bound. Since the existing SCL validation does not check DataSetReference, an unexpected dataset can be reported with SCL-derived facts instead of unknown/conflicting facts; require the wire svID and dataset reference to match before setting the signature/bound state.

Useful? React with 👍 / 👎.

var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray();
var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length;

var observation = new SvFrameObservation
{
Timestamp = timestamp,
EtherType = 0x88BA,
AppId = frame.AppId,
DestinationMac = frame.Destination.ToString(),
VlanId = frame.Vlan?.VlanId,
VlanPriority = frame.Vlan?.PriorityCodePoint,
SvId = first.SvId,
DataSetReference = first.DataSetReference,
ConfigurationRevision = first.ConfigurationRevision,
PayloadBytesPerAsdu = payloadLength,
SampleCounts = asdus.Select(item => item.SampleCount).ToArray(),
DeclaredSampleRate = StableNullable(asdus.Select(item => item.SampleRate)),
DeclaredSampleMode = StableNullable(asdus.Select(item => item.SampleMode)),
NominalFrequencyHz = nominalFrequencyHz,
DataSetSignature = signature
};

var state = _streams.GetOrAdd(
key,
_ => new StreamState(_maximumObservations, _maximumAge));
state.Add(observation, inputKind, profile, diagnostics);
snapshot = state.Snapshot(key);
return true;
}

public bool TryGetSnapshot(SvObservedStreamKey key, out SvStreamObservationSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(key);
if (_streams.TryGetValue(key, out var state))
{
snapshot = state.Snapshot(key);
return true;
}

snapshot = new SvStreamObservationSnapshot();
return false;
}

public IReadOnlyList<SvStreamObservationSnapshot> SnapshotAll()
=> _streams.Select(pair => pair.Value.Snapshot(pair.Key))
.OrderBy(item => item.Key.AppId)
.ThenBy(item => item.Key.SvId, StringComparer.Ordinal)
.ThenBy(item => item.Key.DataSetReference, StringComparer.Ordinal)
.ToArray();

public void Clear() => _streams.Clear();

private static IReadOnlyList<string> ValidateFrameConsistency(IReadOnlyList<SampledValueAsdu> asdus)
{
var diagnostics = new List<string>();
var first = asdus[0];

if (asdus.Any(item => !string.Equals(item.SvId, first.SvId, StringComparison.Ordinal)))
diagnostics.Add("ASDUs inside one Ethernet frame expose different svID values.");
if (asdus.Any(item => !string.Equals(item.DataSetReference, first.DataSetReference, StringComparison.Ordinal)))
diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references.");
if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision))
diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values.");
if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1)
diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths.");

return diagnostics;
}

private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry)
=> new()
{
BType = entry.BType,
Cdc = entry.Cdc,
IsQuality = entry.IsQuality,
IsTimestamp = entry.IsTimestamp
};

private static ushort? StableNullable(IEnumerable<ushort?> values)
{
var distinct = values.Distinct().ToArray();
return distinct.Length == 1 ? distinct[0] : null;
}
}
Loading
Loading