diff --git a/Directory.Build.props b/Directory.Build.props
index b562075..d584239 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -9,6 +9,8 @@
true
true
0.4.0
+ $(GITHUB_SHA)
+ true
https://github.com/masarray/arsvin
git
GPL-3.0-or-later
@@ -16,4 +18,4 @@
Ari Sulistiono
Copyright © 2026 Ari Sulistiono
-
\ No newline at end of file
+
diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md
index d0d8ff5..9935f9b 100644
--- a/docs/sv-profile-infrastructure.md
+++ b/docs/sv-profile-infrastructure.md
@@ -15,7 +15,7 @@ Imported PCAP ────┘ ↓
└── SvConfigurationComparer
```
-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.
+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 use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.
## Observed facts
@@ -75,30 +75,106 @@ A high score is an engineering classification result, not a conformance certific
## Configuration-versus-wire comparison
-`SvConfigurationComparer` compares configured expectations with observed facts for addressing, identifiers, dataset, revision, packing, payload, and declared sampling fields.
+`SvExpectedStreamConfigurationFactory` converts the SCL-bound `SampledValuesPublisherProfile` into a transport-neutral `SvExpectedStreamConfiguration`. The expected configuration includes addressing, identifiers, revision, packing, payload length, declared sampling fields, and ordered dataset signature.
+
+`SvStreamObservationManager` compares that expected configuration with the rolling `SvObservedStreamFacts` for every bound live or PCAP stream. The resulting immutable snapshot carries:
+
+- the expected configuration,
+- field-level findings,
+- comparison mode,
+- exact/warning/error counts,
+- a compact summary such as `Exact`, `2 warnings`, or `1 error`.
Two modes are available:
-- **Strict** — mismatches become errors suitable for validation and live-transmission preflight.
-- **Compatible** — mismatches become warnings suitable for troubleshooting and unfamiliar devices.
+- **Compatible** — the Subscriber default; mismatches become warnings suitable for troubleshooting and unfamiliar devices.
+- **Strict** — explicit opt-in for validation, preflight, and formal test cases; mismatches become errors.
Neither mode stops receive-side capture or decoding. Unknown and conflicting streams remain visible.
-## Current integration boundary
+Before accepting an SCL candidate, the observation manager requires APPID, destination MAC, and VLAN to identify the same configured stream. A candidate that fails this address gate is rejected instead of contaminating observed facts with the wrong dataset layout.
+
+## Subscriber compact state
+
+The selected stream exposes one compact analysis strip instead of permanent large evidence cards:
+
+```text
+PROFILE Generic Layer-2 SV
+CONFIDENCE Unknown · insufficient evidence
+SCL MATCH Exact | N warnings | N errors
+WINDOW duration · observed samples
+```
+
+Detailed detector evidence, configuration findings, and observation diagnostics remain collapsed behind an expandable evidence panel. Capture and visualization continue without requiring repeated manual selection.
+
+Waveform, phasor, and RMS collections use one reset notification per UI refresh. The visual layer withholds partial waveform windows until a complete two-cycle set is available, then retains the most recent complete window if the next refresh is incomplete. Compatible SCL warnings remain warnings and do not force the stream into a blocking `BAD` state.
+
+## Evidence report bundle
+
+Subscriber Export creates two files from the same report snapshot:
+
+- a Markdown engineering report for review and handover,
+- a JSON evidence document using schema `arsvin.sv-subscriber-evidence/v1` for automation, archival, and later comparison.
-`SvStreamObservationManager` now:
+Both files include:
+
+- generation time, product version, informational version, repository, and source commit,
+- live, PCAP, or mixed input provenance,
+- SCL path, adapter, user filter, capture duration, frame counts, parse errors, and filtered-frame counts,
+- per-stream identity, runtime integrity, quality, cursor state, waveform readiness, and phasors,
+- first and last observation timestamps, frame and sample counts, measured rates, sample-counter transitions, and wrap evidence,
+- every stable observed fact with its provenance (`WireObserved`, `CaptureCalculated`, `SclDerived`, or trusted context),
+- profile definition, evidence maturity, confidence, weighted match evidence, and source metadata,
+- expected SCL configuration, comparison mode, exact/warning/error summary, and field-level findings,
+- runtime and observation diagnostics.
+
+Unknown nullable values remain explicit `null`; unknown text fields remain empty strings in JSON and render as `unknown` in Markdown. They are not silently removed or interpreted as matches. The report schema validates generation time, product identity, stream count, and unique stream keys before serialization.
+
+GitHub Actions injects `GITHUB_SHA` into `SourceRevisionId`; the .NET informational version therefore carries the build commit for release and CI artifacts. Local builds without source revision metadata report the commit as `unknown` rather than inventing one.
+
+## Evidence report comparison
+
+The Subscriber **Compare** action accepts a baseline and candidate `arsvin.sv-subscriber-evidence/v1` JSON report and writes a paired comparison bundle:
+
+- Markdown for engineering review,
+- JSON using schema `arsvin.sv-subscriber-evidence-comparison/v1` for automation and regression gates.
+
+Comparison uses the full stable stream key first. Unmatched streams are then paired through a logical identity made from APPID, destination MAC, VLAN, `svID`, and dataset reference. Source MAC is deliberately excluded from that fallback identity so publisher/NIC failover is reported as a source change rather than a false removed-plus-added stream.
+
+The comparison classifies:
+
+- report-level schema, software, commit, capture-source, SCL-source, and health changes,
+- added, removed, changed, and unchanged logical streams,
+- health regression or recovery,
+- source-MAC failover,
+- `confRev`, ASDU packing, sample-rate, and sample-mode changes,
+- sequence gaps, duplicates, out-of-order frames, payload issues, and SCL mismatches,
+- observation-window size and measured-rate changes,
+- SCL binding and configuration-comparison regression,
+- profile identity and confidence changes,
+- payload, counter-wrap, nominal-frequency, dataset-signature, provenance, and diagnostic changes.
+
+Changes are assigned `Info`, `Warning`, or `Error` severity. Removed streams, health transitions to `BAD`/`ERROR`, new out-of-order or payload failures, blocking configuration errors, profile conflicts, and dataset-signature changes are treated as high-signal regressions. Measured-rate changes within the one-percent comparison tolerance do not create false warnings.
+
+## Current integration boundary
-- 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.
+`SvStreamObservationManager` and Subscriber now:
+
+- accept parsed frames from live Npcap capture and classic-PCAP replay;
+- group frames by source, destination, VLAN, APPID, `svID`, and dataset reference;
+- deliberately keep `confRev` outside the key so revision changes remain observable;
+- retain input provenance (`LiveCapture` or `PcapReplay`);
+- validate the SCL candidate against the observed address tuple;
+- convert a bound SCL stream into `SvExpectedStreamConfiguration`;
+- run Compatible comparison by default, with Strict available per observation call;
+- evaluate the built-in evidence-aware profile catalog;
+- expose compact profile, confidence, SCL match, and observation-window state;
+- expose detailed evidence only on demand;
+- keep full-window waveform, phasor, and RMS visualization stable through bulk collection refreshes;
+- serialize the same observation, profile, configuration, provenance, source, and build evidence into paired Markdown and JSON reports; and
+- compare baseline and candidate evidence reports through deterministic, severity-ranked Markdown and JSON output.
## Next integration
-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.
+1. Add profile-specific definitions only after source review and deterministic evidence.
+2. Accumulate real comparison history before defining organization-specific pass/fail policy or CI blocking thresholds.
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs
index 27872d1..f0bf364 100644
--- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs
@@ -46,9 +46,27 @@ public sealed record SvConfigurationComparisonResult
= Array.Empty();
public bool HasBlockingErrors => Findings.Any(item => item.Severity == SvConfigurationFindingSeverity.Error);
+ public int InfoCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Info);
public int ErrorCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Error);
public int WarningCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Warning);
public bool IsExactMatch => Findings.Count == 0;
+
+ public string Summary
+ {
+ get
+ {
+ if (IsExactMatch)
+ return "Exact";
+ if (ErrorCount > 0)
+ return CountText(ErrorCount, "error");
+ if (WarningCount > 0)
+ return CountText(WarningCount, "warning");
+ return CountText(InfoCount, "info");
+ }
+ }
+
+ private static string CountText(int count, string label)
+ => $"{count} {label}{(count == 1 ? string.Empty : "s")}";
}
public sealed class SvConfigurationComparer
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs
new file mode 100644
index 0000000..8a799a3
--- /dev/null
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs
@@ -0,0 +1,54 @@
+using AR.Iec61850.SampledValues;
+using AR.Iec61850.Scl;
+
+namespace AR.Iec61850.SampledValues.Profiles;
+
+public static class SvExpectedStreamConfigurationFactory
+{
+ public static SvExpectedStreamConfiguration Create(SampledValuesPublisherProfile profile)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+
+ return new SvExpectedStreamConfiguration
+ {
+ EtherType = 0x88BA,
+ AppId = profile.AppId,
+ DestinationMac = profile.Destination.ToString(),
+ VlanId = profile.Vlan?.VlanId,
+ VlanPriority = profile.Vlan?.PriorityCodePoint,
+ SvId = profile.Stream.SvId,
+ DataSetReference = profile.Stream.DataSetReference,
+ ConfigurationRevision = profile.Stream.ConfigurationRevision,
+ AsduPerFrame = profile.AsduPerFrame,
+ PayloadBytesPerAsdu = profile.PayloadLayout.PayloadByteLength,
+ DeclaredSampleRate = profile.Stream.SampleRate == 0
+ ? null
+ : profile.Stream.SampleRate,
+ DeclaredSampleMode = MapSampleMode(profile.Stream.SampleMode),
+ DataSetSignature = profile.Entries.Select(ToSignature).ToArray()
+ };
+ }
+
+ private static ushort? MapSampleMode(string sampleMode)
+ {
+ if (string.IsNullOrWhiteSpace(sampleMode))
+ return null;
+
+ return sampleMode.Trim() switch
+ {
+ "SmpPerPeriod" => 0,
+ "SmpPerSec" => 1,
+ "SecPerSmp" => 2,
+ _ => null
+ };
+ }
+
+ private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry)
+ => new()
+ {
+ BType = entry.BType,
+ Cdc = entry.Cdc,
+ IsQuality = entry.IsQuality,
+ IsTimestamp = entry.IsTimestamp
+ };
+}
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs
index 96b8b3b..9b43c2f 100644
--- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs
@@ -55,16 +55,25 @@ public sealed record SvStreamObservationSnapshot
public SvObservationInputKind LastInputKind { get; init; }
public bool IsBoundToScl { get; init; }
public string ControlBlockReference { get; init; } = string.Empty;
+ public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; }
+ public SvConfigurationComparisonResult? ConfigurationComparison { get; init; }
+ public SvProfileDetectionResult? ProfileDetection { get; init; }
+ public string ConfigurationMatchSummary => ConfigurationComparison?.Summary ?? "Not configured";
public IReadOnlyList Diagnostics { get; init; } = Array.Empty();
}
public sealed class SvStreamObservationManager
{
+ public const int DefaultMaximumObservations = 12_288;
+ public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(2);
+
private sealed class StreamState
{
private readonly object _gate = new();
private readonly HashSet _inputKinds = [];
private readonly Queue _diagnostics = new();
+ private SvExpectedStreamConfiguration? _expectedConfiguration;
+ private SvComparisonMode _comparisonMode = SvComparisonMode.Compatible;
public StreamState(int maximumObservations, TimeSpan maximumAge)
{
@@ -80,6 +89,7 @@ public void Add(
SvFrameObservation observation,
SvObservationInputKind inputKind,
SampledValuesPublisherProfile? profile,
+ SvComparisonMode comparisonMode,
IEnumerable diagnostics)
{
Accumulator.Add(observation);
@@ -91,6 +101,8 @@ public void Add(
{
IsBoundToScl = true;
ControlBlockReference = profile.Stream.ControlBlockReference;
+ _expectedConfiguration = SvExpectedStreamConfigurationFactory.Create(profile);
+ _comparisonMode = comparisonMode;
}
foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item)))
@@ -110,6 +122,16 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key)
var facts = Accumulator.BuildFacts();
lock (_gate)
{
+ var comparison = _expectedConfiguration is null
+ ? null
+ : new SvConfigurationComparer().Compare(
+ _expectedConfiguration,
+ facts,
+ _comparisonMode);
+ var profileDetection = new SvProfileDetector().DetectBest(
+ facts,
+ SvProfileCatalog.BuiltIn);
+
return new SvStreamObservationSnapshot
{
Key = key,
@@ -118,6 +140,9 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key)
LastInputKind = LastInputKind,
IsBoundToScl = IsBoundToScl,
ControlBlockReference = ControlBlockReference,
+ ExpectedConfiguration = _expectedConfiguration,
+ ConfigurationComparison = comparison,
+ ProfileDetection = profileDetection,
Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray()
};
}
@@ -129,13 +154,13 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key)
private readonly TimeSpan _maximumAge;
public SvStreamObservationManager(
- int maximumObservations = SvObservationAccumulator.DefaultMaximumObservations,
+ int maximumObservations = DefaultMaximumObservations,
TimeSpan? maximumAge = null)
{
if (maximumObservations < 2)
throw new ArgumentOutOfRangeException(nameof(maximumObservations));
- _maximumAge = maximumAge ?? SvObservationAccumulator.DefaultMaximumAge;
+ _maximumAge = maximumAge ?? DefaultMaximumAge;
if (_maximumAge <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(maximumAge));
@@ -150,7 +175,8 @@ public bool TryObserve(
SvObservationInputKind inputKind,
SampledValuesPublisherProfile? profile,
out SvStreamObservationSnapshot snapshot,
- double? nominalFrequencyHz = null)
+ double? nominalFrequencyHz = null,
+ SvComparisonMode comparisonMode = SvComparisonMode.Compatible)
{
ArgumentNullException.ThrowIfNull(frame);
snapshot = new SvStreamObservationSnapshot();
@@ -161,8 +187,9 @@ public bool TryObserve(
return false;
var key = SvObservedStreamKey.FromFrame(frame);
- var diagnostics = ValidateFrameConsistency(asdus);
- var signature = profile?.Entries.Select(ToSignature).ToArray()
+ var diagnostics = ValidateFrameConsistency(asdus).ToList();
+ var boundProfile = ValidateProfileBinding(frame, profile, diagnostics);
+ var signature = boundProfile?.Entries.Select(ToSignature).ToArray()
?? Array.Empty();
var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray();
var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length;
@@ -189,7 +216,7 @@ public bool TryObserve(
var state = _streams.GetOrAdd(
key,
_ => new StreamState(_maximumObservations, _maximumAge));
- state.Add(observation, inputKind, profile, diagnostics);
+ state.Add(observation, inputKind, boundProfile, comparisonMode, diagnostics);
snapshot = state.Snapshot(key);
return true;
}
@@ -216,6 +243,29 @@ public IReadOnlyList SnapshotAll()
public void Clear() => _streams.Clear();
+ private static SampledValuesPublisherProfile? ValidateProfileBinding(
+ SampledValuesFrame frame,
+ SampledValuesPublisherProfile? profile,
+ ICollection diagnostics)
+ {
+ if (profile is null)
+ return null;
+
+ var appIdMatches = profile.AppId == frame.AppId;
+ var destinationMatches = string.Equals(
+ profile.Destination.ToString(),
+ frame.Destination.ToString(),
+ StringComparison.OrdinalIgnoreCase);
+ var vlanMatches = profile.Vlan?.VlanId == frame.Vlan?.VlanId;
+ if (appIdMatches && destinationMatches && vlanMatches)
+ return profile;
+
+ diagnostics.Add(
+ $"Rejected SCL candidate {profile.Stream.ControlBlockReference}: " +
+ "APPID, destination MAC, and VLAN must identify the same configured stream before comparison.");
+ return null;
+ }
+
private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList asdus)
{
var diagnostics = new List();
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs
new file mode 100644
index 0000000..7215394
--- /dev/null
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs
@@ -0,0 +1,708 @@
+using System.Globalization;
+using AR.Iec61850.SampledValues.Profiles;
+
+namespace AR.Iec61850.SampledValues.Reporting;
+
+public enum SvEvidenceChangeKind
+{
+ Added,
+ Removed,
+ Changed,
+ Unchanged
+}
+
+public enum SvEvidenceChangeSeverity
+{
+ Info,
+ Warning,
+ Error
+}
+
+public sealed record SvSubscriberEvidenceComparison
+{
+ public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence-comparison/v1";
+
+ public string SchemaVersion { get; init; } = CurrentSchemaVersion;
+ public DateTimeOffset GeneratedAt { get; init; }
+ public SvEvidenceReportReference Baseline { get; init; } = new();
+ public SvEvidenceReportReference Candidate { get; init; } = new();
+ public SvEvidenceComparisonSummary Summary { get; init; } = new();
+ public IReadOnlyList ReportChanges { get; init; }
+ = Array.Empty();
+ public IReadOnlyList Streams { get; init; }
+ = Array.Empty();
+
+ public void Validate()
+ {
+ if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Unsupported SV comparison schema '{SchemaVersion}'.");
+ if (GeneratedAt == default)
+ throw new InvalidOperationException("SV comparison requires a generation timestamp.");
+ if (string.IsNullOrWhiteSpace(Baseline.SchemaVersion) || string.IsNullOrWhiteSpace(Candidate.SchemaVersion))
+ throw new InvalidOperationException("SV comparison requires baseline and candidate schema metadata.");
+ if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.ComparisonKey)))
+ throw new InvalidOperationException("Every stream comparison requires a stable comparison key.");
+ if (Streams.Select(stream => stream.ComparisonKey).Distinct(StringComparer.Ordinal).Count() != Streams.Count)
+ throw new InvalidOperationException("SV comparison keys must be unique.");
+
+ var classified = Summary.AddedStreamCount + Summary.RemovedStreamCount +
+ Summary.ChangedStreamCount + Summary.UnchangedStreamCount;
+ if (classified != Streams.Count)
+ throw new InvalidOperationException("SV comparison summary does not match the stream collection.");
+
+ var changes = ReportChanges.Concat(Streams.SelectMany(stream => stream.Changes)).ToArray();
+ if (Summary.InfoChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Info) ||
+ Summary.WarningChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Warning) ||
+ Summary.ErrorChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Error))
+ {
+ throw new InvalidOperationException("SV comparison severity totals do not match the evidence.");
+ }
+ }
+}
+
+public sealed record SvEvidenceReportReference
+{
+ public string SchemaVersion { get; init; } = string.Empty;
+ public DateTimeOffset GeneratedAt { get; init; }
+ public string Product { get; init; } = string.Empty;
+ public string Version { get; init; } = string.Empty;
+ public string Commit { get; init; } = string.Empty;
+ public string CaptureSource { get; init; } = string.Empty;
+ public string Health { get; init; } = string.Empty;
+ public int StreamCount { get; init; }
+}
+
+public sealed record SvEvidenceComparisonSummary
+{
+ public int BaselineStreamCount { get; init; }
+ public int CandidateStreamCount { get; init; }
+ public int AddedStreamCount { get; init; }
+ public int RemovedStreamCount { get; init; }
+ public int ChangedStreamCount { get; init; }
+ public int UnchangedStreamCount { get; init; }
+ public int InfoChangeCount { get; init; }
+ public int WarningChangeCount { get; init; }
+ public int ErrorChangeCount { get; init; }
+ public bool HasRegressions => WarningChangeCount > 0 || ErrorChangeCount > 0;
+}
+
+public sealed record SvSubscriberStreamComparison
+{
+ public string ComparisonKey { get; init; } = string.Empty;
+ public string LogicalStreamKey { get; init; } = string.Empty;
+ public SvEvidenceChangeKind Kind { get; init; }
+ public SvEvidenceChangeSeverity Severity { get; init; }
+ public string BaselineStreamKey { get; init; } = string.Empty;
+ public string CandidateStreamKey { get; init; } = string.Empty;
+ public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new();
+ public IReadOnlyList Changes { get; init; }
+ = Array.Empty();
+}
+
+public sealed record SvEvidenceFieldChange
+{
+ public string Category { get; init; } = string.Empty;
+ public string Field { get; init; } = string.Empty;
+ public SvEvidenceChangeSeverity Severity { get; init; }
+ public string Baseline { get; init; } = string.Empty;
+ public string Candidate { get; init; } = string.Empty;
+ public string Message { get; init; } = string.Empty;
+}
+
+public sealed class SvSubscriberEvidenceComparator
+{
+ private const double RateTolerancePercent = 1.0;
+
+ public SvSubscriberEvidenceComparison Compare(
+ SvSubscriberEvidenceReport baseline,
+ SvSubscriberEvidenceReport candidate,
+ DateTimeOffset generatedAt)
+ {
+ ArgumentNullException.ThrowIfNull(baseline);
+ ArgumentNullException.ThrowIfNull(candidate);
+ baseline.Validate();
+ candidate.Validate();
+ if (generatedAt == default)
+ throw new ArgumentException("Comparison requires a generation timestamp.", nameof(generatedAt));
+
+ var reportChanges = CompareReportMetadata(baseline, candidate);
+ var streams = CompareStreams(baseline.Streams, candidate.Streams);
+ var allChanges = reportChanges.Concat(streams.SelectMany(stream => stream.Changes)).ToArray();
+ var comparison = new SvSubscriberEvidenceComparison
+ {
+ GeneratedAt = generatedAt,
+ Baseline = Reference(baseline),
+ Candidate = Reference(candidate),
+ ReportChanges = reportChanges,
+ Streams = streams,
+ Summary = new SvEvidenceComparisonSummary
+ {
+ BaselineStreamCount = baseline.Streams.Count,
+ CandidateStreamCount = candidate.Streams.Count,
+ AddedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Added),
+ RemovedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Removed),
+ ChangedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Changed),
+ UnchangedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Unchanged),
+ InfoChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Info),
+ WarningChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Warning),
+ ErrorChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Error)
+ }
+ };
+ comparison.Validate();
+ return comparison;
+ }
+
+ private static IReadOnlyList CompareReportMetadata(
+ SvSubscriberEvidenceReport baseline,
+ SvSubscriberEvidenceReport candidate)
+ {
+ var changes = new List();
+ TextChange(changes, "Report", "Schema version", baseline.SchemaVersion, candidate.SchemaVersion,
+ SvEvidenceChangeSeverity.Error, "Evidence schema changed; compatibility must be reviewed.");
+ TextChange(changes, "Software", "Product", baseline.Software.Product, candidate.Software.Product,
+ SvEvidenceChangeSeverity.Warning, "Product identity changed.");
+ TextChange(changes, "Software", "Version", baseline.Software.Version, candidate.Software.Version,
+ SvEvidenceChangeSeverity.Info, "Software version changed.");
+ TextChange(changes, "Software", "Commit", baseline.Software.Commit, candidate.Software.Commit,
+ SvEvidenceChangeSeverity.Info, "Build commit changed.");
+ TextChange(changes, "Capture", "Source", baseline.Capture.Source, candidate.Capture.Source,
+ SvEvidenceChangeSeverity.Info, "Capture source changed.");
+ TextChange(changes, "Capture", "SCL path", baseline.Capture.SclPath, candidate.Capture.SclPath,
+ SvEvidenceChangeSeverity.Info, "SCL source changed.");
+ HealthChange(changes, "Report", baseline.Summary.Health, candidate.Summary.Health);
+ return changes;
+ }
+
+ private static IReadOnlyList CompareStreams(
+ IReadOnlyList baseline,
+ IReadOnlyList candidate)
+ {
+ var comparisons = new List();
+ var candidateByKey = candidate.ToDictionary(stream => stream.Key, StringComparer.Ordinal);
+ var usedCandidateKeys = new HashSet(StringComparer.Ordinal);
+ var unmatchedBaseline = new List();
+
+ foreach (var baselineStream in baseline)
+ {
+ if (candidateByKey.TryGetValue(baselineStream.Key, out var exact))
+ {
+ comparisons.Add(Pair(baselineStream, exact));
+ usedCandidateKeys.Add(exact.Key);
+ }
+ else
+ {
+ unmatchedBaseline.Add(baselineStream);
+ }
+ }
+
+ var unmatchedCandidate = candidate.Where(stream => !usedCandidateKeys.Contains(stream.Key)).ToArray();
+ var baselineLogical = unmatchedBaseline.GroupBy(LogicalKey, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal);
+ var candidateLogical = unmatchedCandidate.GroupBy(LogicalKey, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal);
+
+ foreach (var baselineStream in unmatchedBaseline)
+ {
+ var logicalKey = LogicalKey(baselineStream);
+ if (baselineLogical[logicalKey].Length == 1 &&
+ candidateLogical.TryGetValue(logicalKey, out var candidates) &&
+ candidates.Length == 1 &&
+ usedCandidateKeys.Add(candidates[0].Key))
+ {
+ comparisons.Add(Pair(baselineStream, candidates[0]));
+ }
+ else
+ {
+ comparisons.Add(Removed(baselineStream));
+ }
+ }
+
+ foreach (var candidateStream in candidate.Where(stream => !usedCandidateKeys.Contains(stream.Key)))
+ comparisons.Add(Added(candidateStream));
+
+ return comparisons.OrderBy(stream => stream.Identity.AppId)
+ .ThenBy(stream => stream.Identity.SvId, StringComparer.Ordinal)
+ .ThenBy(stream => stream.Kind)
+ .ThenBy(stream => stream.ComparisonKey, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static SvSubscriberStreamComparison Pair(
+ SvSubscriberStreamEvidence baseline,
+ SvSubscriberStreamEvidence candidate)
+ {
+ var changes = new List();
+ HealthChange(changes, "Stream", baseline.Health, candidate.Health);
+ TextChange(changes, "Identity", "Source MAC", baseline.Identity.SourceMac, candidate.Identity.SourceMac,
+ SvEvidenceChangeSeverity.Info, "Publisher source MAC changed while logical identity remained stable.");
+ UIntChange(changes, "Identity", "confRev", baseline.Identity.ConfigurationRevision,
+ candidate.Identity.ConfigurationRevision, SvEvidenceChangeSeverity.Warning, "Configuration revision changed.");
+ IntChange(changes, "Identity", "ASDU per frame", baseline.Identity.AsduPerFrame,
+ candidate.Identity.AsduPerFrame, SvEvidenceChangeSeverity.Warning, "ASDU packing changed.");
+ UShortChange(changes, "Identity", "Declared sample rate", baseline.Identity.DeclaredSampleRate,
+ candidate.Identity.DeclaredSampleRate, SvEvidenceChangeSeverity.Warning, "Declared sample rate changed.");
+ UShortChange(changes, "Identity", "Declared sample mode", baseline.Identity.DeclaredSampleMode,
+ candidate.Identity.DeclaredSampleMode, SvEvidenceChangeSeverity.Warning, "Declared sample mode changed.");
+
+ IssueCounter(changes, "Sequence gaps", baseline.Runtime.SequenceGapCount, candidate.Runtime.SequenceGapCount,
+ SvEvidenceChangeSeverity.Warning);
+ IssueCounter(changes, "Duplicates", baseline.Runtime.DuplicateCount, candidate.Runtime.DuplicateCount,
+ SvEvidenceChangeSeverity.Warning);
+ IssueCounter(changes, "Out-of-order", baseline.Runtime.OutOfOrderCount, candidate.Runtime.OutOfOrderCount,
+ SvEvidenceChangeSeverity.Error);
+ IssueCounter(changes, "Payload issues", baseline.Runtime.PayloadIssueCount, candidate.Runtime.PayloadIssueCount,
+ SvEvidenceChangeSeverity.Error);
+ IssueCounter(changes, "SCL mismatches", baseline.Runtime.SclMismatchCount, candidate.Runtime.SclMismatchCount,
+ SvEvidenceChangeSeverity.Warning);
+
+ RateChange(changes, "Observed frames/s", baseline.Observation.Facts.ObservedFramesPerSecond,
+ candidate.Observation.Facts.ObservedFramesPerSecond);
+ RateChange(changes, "Observed samples/s", baseline.Observation.Facts.ObservedSamplesPerSecond,
+ candidate.Observation.Facts.ObservedSamplesPerSecond);
+ WindowChanges(changes, baseline.Observation, candidate.Observation);
+ BindingChanges(changes, baseline.Observation, candidate.Observation);
+ ProfileChanges(changes, baseline.Observation.ProfileDetection, candidate.Observation.ProfileDetection);
+ ConfigurationChanges(changes, baseline.Observation.ConfigurationComparison,
+ candidate.Observation.ConfigurationComparison);
+ FactChanges(changes, baseline.Observation.Facts, candidate.Observation.Facts);
+ DiagnosticChanges(changes,
+ baseline.Diagnostics.Concat(baseline.Observation.Diagnostics),
+ candidate.Diagnostics.Concat(candidate.Observation.Diagnostics));
+
+ var logicalKey = LogicalKey(candidate);
+ return new SvSubscriberStreamComparison
+ {
+ ComparisonKey = $"PAIR|{baseline.Key}|{candidate.Key}",
+ LogicalStreamKey = logicalKey,
+ Kind = changes.Count == 0 ? SvEvidenceChangeKind.Unchanged : SvEvidenceChangeKind.Changed,
+ Severity = changes.Select(change => change.Severity)
+ .DefaultIfEmpty(SvEvidenceChangeSeverity.Info).Max(),
+ BaselineStreamKey = baseline.Key,
+ CandidateStreamKey = candidate.Key,
+ Identity = candidate.Identity,
+ Changes = changes
+ };
+ }
+
+ private static void WindowChanges(
+ ICollection changes,
+ SvSubscriberObservationEvidence baseline,
+ SvSubscriberObservationEvidence candidate)
+ {
+ if (baseline.WindowSamples != candidate.WindowSamples)
+ {
+ var severity = baseline.WindowSamples > 0 && candidate.WindowSamples < baseline.WindowSamples / 2
+ ? SvEvidenceChangeSeverity.Warning
+ : SvEvidenceChangeSeverity.Info;
+ Change(changes, "Observation window", "Samples", severity,
+ baseline.WindowSamples.ToString(CultureInfo.InvariantCulture),
+ candidate.WindowSamples.ToString(CultureInfo.InvariantCulture),
+ severity == SvEvidenceChangeSeverity.Warning
+ ? "Candidate observation window contains materially fewer samples."
+ : "Observation-window sample count changed.");
+ }
+
+ if (!WithinPercent(baseline.WindowDurationSeconds, candidate.WindowDurationSeconds, 0.01))
+ {
+ Change(changes, "Observation window", "Duration", SvEvidenceChangeSeverity.Info,
+ Number(baseline.WindowDurationSeconds), Number(candidate.WindowDurationSeconds),
+ "Observation-window duration changed.");
+ }
+
+ TextChange(changes, "Observation window", "Input kinds",
+ string.Join(", ", baseline.InputKinds), string.Join(", ", candidate.InputKinds),
+ SvEvidenceChangeSeverity.Info, "Observation input provenance changed.");
+ }
+
+ private static void BindingChanges(
+ ICollection changes,
+ SvSubscriberObservationEvidence baseline,
+ SvSubscriberObservationEvidence candidate)
+ {
+ if (baseline.IsBoundToScl != candidate.IsBoundToScl)
+ {
+ var severity = baseline.IsBoundToScl && !candidate.IsBoundToScl
+ ? SvEvidenceChangeSeverity.Warning
+ : SvEvidenceChangeSeverity.Info;
+ Change(changes, "SCL", "Binding", severity,
+ baseline.IsBoundToScl ? "bound" : "not bound",
+ candidate.IsBoundToScl ? "bound" : "not bound",
+ severity == SvEvidenceChangeSeverity.Warning
+ ? "Candidate stream lost its SCL binding."
+ : "Candidate stream gained an SCL binding.");
+ }
+
+ TextChange(changes, "SCL", "Control block", baseline.ControlBlockReference,
+ candidate.ControlBlockReference, SvEvidenceChangeSeverity.Info,
+ "SCL control-block reference changed.");
+ }
+
+ private static void ProfileChanges(
+ ICollection changes,
+ SvProfileDetectionResult? baseline,
+ SvProfileDetectionResult? candidate)
+ {
+ TextChange(changes, "Profile", "Profile ID", baseline?.Profile.Id, candidate?.Profile.Id,
+ SvEvidenceChangeSeverity.Warning, "Detected profile changed.");
+
+ var baselineConfidence = baseline?.Confidence ?? SvProfileConfidence.Unknown;
+ var candidateConfidence = candidate?.Confidence ?? SvProfileConfidence.Unknown;
+ if (baselineConfidence == candidateConfidence)
+ return;
+
+ var severity = candidateConfidence == SvProfileConfidence.Conflict
+ ? SvEvidenceChangeSeverity.Error
+ : ConfidenceRank(candidateConfidence) < ConfidenceRank(baselineConfidence)
+ ? SvEvidenceChangeSeverity.Warning
+ : SvEvidenceChangeSeverity.Info;
+ Change(changes, "Profile", "Confidence", severity,
+ baselineConfidence.ToString(), candidateConfidence.ToString(),
+ severity == SvEvidenceChangeSeverity.Error
+ ? "Candidate profile classification conflicts with observed evidence."
+ : severity == SvEvidenceChangeSeverity.Warning
+ ? "Candidate profile confidence decreased."
+ : "Candidate profile confidence improved.");
+ }
+
+ private static void ConfigurationChanges(
+ ICollection changes,
+ SvConfigurationComparisonResult? baseline,
+ SvConfigurationComparisonResult? candidate)
+ {
+ var baselineSummary = baseline?.Summary ?? "Not configured";
+ var candidateSummary = candidate?.Summary ?? "Not configured";
+ if (string.Equals(baselineSummary, candidateSummary, StringComparison.Ordinal) &&
+ baseline?.Mode == candidate?.Mode)
+ return;
+
+ var blockingIntroduced = candidate?.HasBlockingErrors == true && baseline?.HasBlockingErrors != true;
+ var warningsIncreased = (candidate?.WarningCount ?? 0) > (baseline?.WarningCount ?? 0);
+ var severity = blockingIntroduced
+ ? SvEvidenceChangeSeverity.Error
+ : warningsIncreased || (baseline is not null && candidate is null)
+ ? SvEvidenceChangeSeverity.Warning
+ : SvEvidenceChangeSeverity.Info;
+ Change(changes, "Configuration", "Comparison", severity, baselineSummary, candidateSummary,
+ blockingIntroduced
+ ? "Candidate introduced blocking configuration errors."
+ : severity == SvEvidenceChangeSeverity.Warning
+ ? "Candidate configuration evidence regressed."
+ : "Configuration comparison result changed.");
+ }
+
+ private static void FactChanges(
+ ICollection changes,
+ SvObservedStreamFacts baseline,
+ SvObservedStreamFacts candidate)
+ {
+ IntChange(changes, "Observed facts", "Payload bytes per ASDU", baseline.PayloadBytesPerAsdu,
+ candidate.PayloadBytesPerAsdu, SvEvidenceChangeSeverity.Warning, "Observed payload length changed.");
+ IntChange(changes, "Observed facts", "Counter wrap", baseline.ObservedCounterWrap,
+ candidate.ObservedCounterWrap, SvEvidenceChangeSeverity.Warning, "Observed sample-counter wrap changed.");
+ DoubleChange(changes, "Observed facts", "Nominal frequency", baseline.NominalFrequencyHz,
+ candidate.NominalFrequencyHz, SvEvidenceChangeSeverity.Warning, "Nominal-frequency context changed.");
+ TextChange(changes, "Observed facts", "Dataset signature", Signature(baseline.DataSetSignature),
+ Signature(candidate.DataSetSignature), SvEvidenceChangeSeverity.Error,
+ "Observed dataset element order or types changed.");
+
+ foreach (var key in baseline.Provenance.Keys.Concat(candidate.Provenance.Keys)
+ .Distinct(StringComparer.Ordinal))
+ {
+ var baselineSource = baseline.Provenance.TryGetValue(key, out var b) ? b : SvFactSource.Unknown;
+ var candidateSource = candidate.Provenance.TryGetValue(key, out var c) ? c : SvFactSource.Unknown;
+ if (baselineSource != candidateSource)
+ {
+ Change(changes, "Provenance", key, SvEvidenceChangeSeverity.Info,
+ baselineSource.ToString(), candidateSource.ToString(), "Fact provenance changed.");
+ }
+ }
+ }
+
+ private static void DiagnosticChanges(
+ ICollection changes,
+ IEnumerable baseline,
+ IEnumerable candidate)
+ {
+ var baselineSet = baseline.Where(value => !string.IsNullOrWhiteSpace(value))
+ .ToHashSet(StringComparer.Ordinal);
+ var candidateSet = candidate.Where(value => !string.IsNullOrWhiteSpace(value))
+ .ToHashSet(StringComparer.Ordinal);
+
+ foreach (var value in candidateSet.Except(baselineSet, StringComparer.Ordinal)
+ .OrderBy(value => value, StringComparer.Ordinal))
+ {
+ Change(changes, "Diagnostics", "Added", SvEvidenceChangeSeverity.Warning,
+ "-", value, "Candidate introduced a diagnostic.");
+ }
+
+ foreach (var value in baselineSet.Except(candidateSet, StringComparer.Ordinal)
+ .OrderBy(value => value, StringComparer.Ordinal))
+ {
+ Change(changes, "Diagnostics", "Resolved", SvEvidenceChangeSeverity.Info,
+ value, "-", "A baseline diagnostic is no longer present.");
+ }
+ }
+
+ private static void IssueCounter(
+ ICollection changes,
+ string field,
+ int baseline,
+ int candidate,
+ SvEvidenceChangeSeverity regressionSeverity)
+ {
+ if (baseline == candidate)
+ return;
+ var severity = candidate > baseline ? regressionSeverity : SvEvidenceChangeSeverity.Info;
+ Change(changes, "Runtime integrity", field, severity,
+ baseline.ToString(CultureInfo.InvariantCulture), candidate.ToString(CultureInfo.InvariantCulture),
+ candidate > baseline ? $"Candidate {field.ToLowerInvariant()} increased." : $"Candidate {field.ToLowerInvariant()} decreased.");
+ }
+
+ private static void RateChange(
+ ICollection changes,
+ string field,
+ double? baseline,
+ double? candidate)
+ {
+ if (!baseline.HasValue && !candidate.HasValue)
+ return;
+ if (!baseline.HasValue || !candidate.HasValue)
+ {
+ Change(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning,
+ Number(baseline), Number(candidate), "Observed rate availability changed.");
+ return;
+ }
+ if (WithinPercent(baseline.Value, candidate.Value, RateTolerancePercent))
+ return;
+
+ Change(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning,
+ Number(baseline), Number(candidate),
+ $"Observed rate changed by more than {RateTolerancePercent:0.###}%.");
+ }
+
+ private static void HealthChange(
+ ICollection changes,
+ string category,
+ string? baseline,
+ string? candidate)
+ {
+ if (string.Equals(baseline, candidate, StringComparison.OrdinalIgnoreCase))
+ return;
+ var candidateRank = HealthRank(candidate);
+ var severity = candidateRank > HealthRank(baseline)
+ ? candidateRank >= 2 ? SvEvidenceChangeSeverity.Error : SvEvidenceChangeSeverity.Warning
+ : SvEvidenceChangeSeverity.Info;
+ Change(changes, category, "Health", severity, Text(baseline), Text(candidate),
+ severity == SvEvidenceChangeSeverity.Info ? "Health improved or changed without regression." : "Health regressed.");
+ }
+
+ private static void TextChange(
+ ICollection changes,
+ string category,
+ string field,
+ string? baseline,
+ string? candidate,
+ SvEvidenceChangeSeverity severity,
+ string message)
+ {
+ if (!string.Equals(baseline ?? string.Empty, candidate ?? string.Empty, StringComparison.Ordinal))
+ Change(changes, category, field, severity, Text(baseline), Text(candidate), message);
+ }
+
+ private static void IntChange(
+ ICollection changes,
+ string category,
+ string field,
+ int? baseline,
+ int? candidate,
+ SvEvidenceChangeSeverity severity,
+ string message)
+ {
+ if (baseline != candidate)
+ Change(changes, category, field, severity, Value(baseline), Value(candidate), message);
+ }
+
+ private static void UIntChange(
+ ICollection changes,
+ string category,
+ string field,
+ uint? baseline,
+ uint? candidate,
+ SvEvidenceChangeSeverity severity,
+ string message)
+ {
+ if (baseline != candidate)
+ Change(changes, category, field, severity, Value(baseline), Value(candidate), message);
+ }
+
+ private static void UShortChange(
+ ICollection changes,
+ string category,
+ string field,
+ ushort? baseline,
+ ushort? candidate,
+ SvEvidenceChangeSeverity severity,
+ string message)
+ {
+ if (baseline != candidate)
+ Change(changes, category, field, severity, Value(baseline), Value(candidate), message);
+ }
+
+ private static void DoubleChange(
+ ICollection changes,
+ string category,
+ string field,
+ double? baseline,
+ double? candidate,
+ SvEvidenceChangeSeverity severity,
+ string message)
+ {
+ if (baseline != candidate)
+ Change(changes, category, field, severity, Number(baseline), Number(candidate), message);
+ }
+
+ private static void Change(
+ ICollection changes,
+ string category,
+ string field,
+ SvEvidenceChangeSeverity severity,
+ string baseline,
+ string candidate,
+ string message)
+ => changes.Add(new SvEvidenceFieldChange
+ {
+ Category = category,
+ Field = field,
+ Severity = severity,
+ Baseline = baseline,
+ Candidate = candidate,
+ Message = message
+ });
+
+ private static SvSubscriberStreamComparison Added(SvSubscriberStreamEvidence stream)
+ {
+ var logicalKey = LogicalKey(stream);
+ return new SvSubscriberStreamComparison
+ {
+ ComparisonKey = $"ADD|{stream.Key}",
+ LogicalStreamKey = logicalKey,
+ Kind = SvEvidenceChangeKind.Added,
+ Severity = SvEvidenceChangeSeverity.Info,
+ CandidateStreamKey = stream.Key,
+ Identity = stream.Identity,
+ Changes =
+ [
+ new SvEvidenceFieldChange
+ {
+ Category = "Stream",
+ Field = "Presence",
+ Severity = SvEvidenceChangeSeverity.Info,
+ Baseline = "absent",
+ Candidate = "present",
+ Message = "Candidate contains a new logical stream."
+ }
+ ]
+ };
+ }
+
+ private static SvSubscriberStreamComparison Removed(SvSubscriberStreamEvidence stream)
+ {
+ var logicalKey = LogicalKey(stream);
+ return new SvSubscriberStreamComparison
+ {
+ ComparisonKey = $"REMOVE|{stream.Key}",
+ LogicalStreamKey = logicalKey,
+ Kind = SvEvidenceChangeKind.Removed,
+ Severity = SvEvidenceChangeSeverity.Error,
+ BaselineStreamKey = stream.Key,
+ Identity = stream.Identity,
+ Changes =
+ [
+ new SvEvidenceFieldChange
+ {
+ Category = "Stream",
+ Field = "Presence",
+ Severity = SvEvidenceChangeSeverity.Error,
+ Baseline = "present",
+ Candidate = "absent",
+ Message = "Candidate no longer contains this logical stream."
+ }
+ ]
+ };
+ }
+
+ private static SvEvidenceReportReference Reference(SvSubscriberEvidenceReport report)
+ => new()
+ {
+ SchemaVersion = report.SchemaVersion,
+ GeneratedAt = report.GeneratedAt,
+ Product = report.Software.Product,
+ Version = report.Software.Version,
+ Commit = report.Software.Commit,
+ CaptureSource = report.Capture.Source,
+ Health = report.Summary.Health,
+ StreamCount = report.Streams.Count
+ };
+
+ private static string LogicalKey(SvSubscriberStreamEvidence stream)
+ {
+ var identity = stream.Identity;
+ var vlan = identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "-";
+ return $"SV|{identity.AppId:X4}|{NormalizeMac(identity.DestinationMac)}|{vlan}|" +
+ $"{NormalizeId(identity.SvId)}|{NormalizeId(identity.DataSetReference)}";
+ }
+
+ private static string NormalizeMac(string? value)
+ => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray());
+
+ private static string NormalizeId(string? value)
+ => (value ?? string.Empty).Trim().ToUpperInvariant();
+
+ private static int HealthRank(string? value)
+ => (value ?? string.Empty).Trim().ToUpperInvariant() switch
+ {
+ "GOOD" => 0,
+ "IDLE" => 0,
+ "LISTENING" => 0,
+ "WARN" => 1,
+ "BAD" => 2,
+ "ERROR" => 3,
+ _ => 1
+ };
+
+ private static int ConfidenceRank(SvProfileConfidence value)
+ => value switch
+ {
+ SvProfileConfidence.Confirmed => 4,
+ SvProfileConfidence.Likely => 3,
+ SvProfileConfidence.Possible => 2,
+ SvProfileConfidence.Unknown => 1,
+ SvProfileConfidence.Conflict => 0,
+ _ => 0
+ };
+
+ private static bool WithinPercent(double baseline, double candidate, double tolerancePercent)
+ {
+ if (baseline == 0)
+ return candidate == 0;
+ return Math.Abs(candidate - baseline) / Math.Abs(baseline) * 100 <= tolerancePercent;
+ }
+
+ private static string Signature(IReadOnlyList signature)
+ => signature.Count == 0
+ ? string.Empty
+ : string.Join(",", signature.Select(item =>
+ $"{item.NormalizedBType}|{item.NormalizedCdc}|{item.IsQuality}|{item.IsTimestamp}"));
+
+ private static string Number(double? value)
+ => value.HasValue ? Number(value.Value) : "unknown";
+
+ private static string Number(double value)
+ => value.ToString("0.###", CultureInfo.InvariantCulture);
+
+ private static string Value(T? value) where T : struct
+ => value.HasValue ? Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? "unknown" : "unknown";
+
+ private static string Text(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "unknown" : value;
+}
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs
new file mode 100644
index 0000000..7d75820
--- /dev/null
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs
@@ -0,0 +1,177 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AR.Iec61850.SampledValues.Reporting;
+
+public static class SvSubscriberEvidenceComparisonSerializer
+{
+ private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
+
+ public static string ToJson(SvSubscriberEvidenceComparison comparison)
+ {
+ ArgumentNullException.ThrowIfNull(comparison);
+ comparison.Validate();
+ return JsonSerializer.Serialize(comparison, JsonOptions);
+ }
+
+ public static SvSubscriberEvidenceComparison FromJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ throw new ArgumentException("SV comparison JSON cannot be empty.", nameof(json));
+
+ var comparison = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidDataException("SV comparison JSON did not contain a comparison document.");
+ comparison.Validate();
+ return comparison;
+ }
+
+ public static string ToMarkdown(SvSubscriberEvidenceComparison comparison)
+ {
+ ArgumentNullException.ThrowIfNull(comparison);
+ comparison.Validate();
+
+ var builder = new StringBuilder();
+ builder.AppendLine("# ARSVIN Subscriber Evidence Comparison");
+ builder.AppendLine();
+ builder.AppendLine("> Baseline-versus-candidate engineering evidence. Warnings and errors identify regressions for review; this is not a formal IEC 61850 conformance certificate.");
+ builder.AppendLine();
+ builder.AppendLine("## Comparison metadata");
+ builder.AppendLine();
+ KeyValueTable(builder,
+ [
+ ("Schema", comparison.SchemaVersion),
+ ("Generated", Timestamp(comparison.GeneratedAt)),
+ ("Baseline generated", Timestamp(comparison.Baseline.GeneratedAt)),
+ ("Baseline version", comparison.Baseline.Version),
+ ("Baseline commit", comparison.Baseline.Commit),
+ ("Baseline capture", comparison.Baseline.CaptureSource),
+ ("Candidate generated", Timestamp(comparison.Candidate.GeneratedAt)),
+ ("Candidate version", comparison.Candidate.Version),
+ ("Candidate commit", comparison.Candidate.Commit),
+ ("Candidate capture", comparison.Candidate.CaptureSource)
+ ]);
+
+ builder.AppendLine("## Summary");
+ builder.AppendLine();
+ KeyValueTable(builder,
+ [
+ ("Baseline streams", comparison.Summary.BaselineStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Candidate streams", comparison.Summary.CandidateStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Added", comparison.Summary.AddedStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Removed", comparison.Summary.RemovedStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Changed", comparison.Summary.ChangedStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Unchanged", comparison.Summary.UnchangedStreamCount.ToString(CultureInfo.InvariantCulture)),
+ ("Info changes", comparison.Summary.InfoChangeCount.ToString(CultureInfo.InvariantCulture)),
+ ("Warnings", comparison.Summary.WarningChangeCount.ToString(CultureInfo.InvariantCulture)),
+ ("Errors", comparison.Summary.ErrorChangeCount.ToString(CultureInfo.InvariantCulture)),
+ ("Regression status", comparison.Summary.HasRegressions ? "REVIEW REQUIRED" : "NO REGRESSION DETECTED")
+ ]);
+
+ Changes(builder, "Report-level changes", comparison.ReportChanges);
+
+ builder.AppendLine("## Stream comparison");
+ builder.AppendLine();
+ builder.AppendLine("| Kind | Severity | APPID | svID | Dataset | Changes |");
+ builder.AppendLine("|---|---|---:|---|---|---:|");
+ foreach (var stream in comparison.Streams)
+ {
+ builder.Append("| ").Append(Cell(stream.Kind.ToString()))
+ .Append(" | ").Append(Cell(stream.Severity.ToString()))
+ .Append(" | 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture))
+ .Append(" | ").Append(Cell(stream.Identity.SvId))
+ .Append(" | ").Append(Cell(stream.Identity.DataSetReference))
+ .Append(" | ").Append(stream.Changes.Count.ToString(CultureInfo.InvariantCulture))
+ .AppendLine(" |");
+ }
+ builder.AppendLine();
+
+ foreach (var stream in comparison.Streams.Where(stream => stream.Changes.Count > 0))
+ {
+ builder.Append("## 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture))
+ .Append(" — ").AppendLine(Heading(stream.Identity.SvId));
+ builder.AppendLine();
+ KeyValueTable(builder,
+ [
+ ("Kind", stream.Kind.ToString()),
+ ("Severity", stream.Severity.ToString()),
+ ("Logical stream key", stream.LogicalStreamKey),
+ ("Comparison key", stream.ComparisonKey),
+ ("Baseline stream key", Empty(stream.BaselineStreamKey)),
+ ("Candidate stream key", Empty(stream.CandidateStreamKey)),
+ ("Destination MAC", stream.Identity.DestinationMac),
+ ("VLAN", stream.Identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "untagged"),
+ ("Dataset", stream.Identity.DataSetReference)
+ ]);
+ Changes(builder, "Changes", stream.Changes);
+ }
+
+ return builder.ToString();
+ }
+
+ private static void Changes(
+ StringBuilder builder,
+ string title,
+ IReadOnlyList changes)
+ {
+ builder.Append("## ").AppendLine(title);
+ builder.AppendLine();
+ if (changes.Count == 0)
+ {
+ builder.AppendLine("- No changes.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.AppendLine("| Severity | Category | Field | Baseline | Candidate | Message |");
+ builder.AppendLine("|---|---|---|---|---|---|");
+ foreach (var change in changes)
+ {
+ builder.Append("| ").Append(Cell(change.Severity.ToString()))
+ .Append(" | ").Append(Cell(change.Category))
+ .Append(" | ").Append(Cell(change.Field))
+ .Append(" | ").Append(Cell(change.Baseline))
+ .Append(" | ").Append(Cell(change.Candidate))
+ .Append(" | ").Append(Cell(change.Message)).AppendLine(" |");
+ }
+ builder.AppendLine();
+ }
+
+ private static void KeyValueTable(
+ StringBuilder builder,
+ IEnumerable<(string Key, string Value)> rows)
+ {
+ builder.AppendLine("| Field | Value |");
+ builder.AppendLine("|---|---|");
+ foreach (var row in rows)
+ builder.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(Empty(row.Value))).AppendLine(" |");
+ builder.AppendLine();
+ }
+
+ private static JsonSerializerOptions CreateJsonOptions()
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true
+ };
+ options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
+ return options;
+ }
+
+ private static string Timestamp(DateTimeOffset value)
+ => value.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture);
+
+ private static string Empty(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value;
+
+ private static string Cell(string? value)
+ => Empty(value).Replace("|", "\\|", StringComparison.Ordinal)
+ .Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal);
+
+ private static string Heading(string? value)
+ => Empty(value).Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal);
+}
diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs
new file mode 100644
index 0000000..ee6bdec
--- /dev/null
+++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs
@@ -0,0 +1,587 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AR.Iec61850.SampledValues.Profiles;
+
+namespace AR.Iec61850.SampledValues.Reporting;
+
+public sealed record SvSubscriberEvidenceReport
+{
+ public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence/v1";
+
+ public string SchemaVersion { get; init; } = CurrentSchemaVersion;
+ public DateTimeOffset GeneratedAt { get; init; }
+ public SvSubscriberSoftwareEvidence Software { get; init; } = new();
+ public SvSubscriberCaptureEvidence Capture { get; init; } = new();
+ public SvSubscriberSummaryEvidence Summary { get; init; } = new();
+ public IReadOnlyList Streams { get; init; }
+ = Array.Empty();
+
+ public void Validate()
+ {
+ if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Unsupported SV report schema '{SchemaVersion}'.");
+ if (GeneratedAt == default)
+ throw new InvalidOperationException("SV report requires a generation timestamp.");
+ if (string.IsNullOrWhiteSpace(Software.Product))
+ throw new InvalidOperationException("SV report requires a product name.");
+ if (Summary.StreamCount != Streams.Count)
+ throw new InvalidOperationException("SV report summary stream count does not match the stream evidence collection.");
+ if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.Key)))
+ throw new InvalidOperationException("Every SV report stream requires a stable key.");
+ if (Streams.Select(stream => stream.Key).Distinct(StringComparer.Ordinal).Count() != Streams.Count)
+ throw new InvalidOperationException("SV report stream keys must be unique.");
+ }
+}
+
+public sealed record SvSubscriberSoftwareEvidence
+{
+ public string Product { get; init; } = string.Empty;
+ public string Version { get; init; } = string.Empty;
+ public string InformationalVersion { get; init; } = string.Empty;
+ public string Commit { get; init; } = string.Empty;
+ public string Repository { get; init; } = string.Empty;
+}
+
+public sealed record SvSubscriberCaptureEvidence
+{
+ public string Source { get; init; } = "Unknown";
+ public string SclPath { get; init; } = string.Empty;
+ public string Adapter { get; init; } = string.Empty;
+ public string Filter { get; init; } = string.Empty;
+ public DateTimeOffset? StartedAt { get; init; }
+ public DateTimeOffset EndedAt { get; init; }
+ public double DurationSeconds { get; init; }
+ public long RawFrames { get; init; }
+ public long SvFrames { get; init; }
+ public long ParseErrors { get; init; }
+ public long DroppedByFilter { get; init; }
+}
+
+public sealed record SvSubscriberSummaryEvidence
+{
+ public string Health { get; init; } = "IDLE";
+ public int StreamCount { get; init; }
+ public int RuntimeIssueCount { get; init; }
+ public int ConfigurationFindingCount { get; init; }
+}
+
+public sealed record SvSubscriberStreamEvidence
+{
+ public string Key { get; init; } = string.Empty;
+ public string Health { get; init; } = "IDLE";
+ public string HealthDetail { get; init; } = string.Empty;
+ public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new();
+ public SvSubscriberRuntimeEvidence Runtime { get; init; } = new();
+ public SvSubscriberObservationEvidence Observation { get; init; } = new();
+ public IReadOnlyList Phasors { get; init; }
+ = Array.Empty();
+ public IReadOnlyList Diagnostics { get; init; }
+ = Array.Empty();
+}
+
+public sealed record SvSubscriberStreamIdentityEvidence
+{
+ public ushort AppId { get; init; }
+ public string SourceMac { get; init; } = string.Empty;
+ public string DestinationMac { get; init; } = string.Empty;
+ public ushort? VlanId { get; init; }
+ public byte? VlanPriority { get; init; }
+ public string SvId { get; init; } = string.Empty;
+ public string DataSetReference { get; init; } = string.Empty;
+ public uint? ConfigurationRevision { get; init; }
+ public int AsduPerFrame { get; init; }
+ public ushort? LastSampleCount { get; init; }
+ public ushort? DeclaredSampleRate { get; init; }
+ public ushort? DeclaredSampleMode { get; init; }
+ public byte? SampleSynchronization { get; init; }
+}
+
+public sealed record SvSubscriberRuntimeEvidence
+{
+ public long FrameCount { get; init; }
+ public long AsduCount { get; init; }
+ public double ActualFramesPerSecond { get; init; }
+ public double AverageFrameGapMilliseconds { get; init; }
+ public double MaximumFrameGapMilliseconds { get; init; }
+ public int SequenceGapCount { get; init; }
+ public int DuplicateCount { get; init; }
+ public int OutOfOrderCount { get; init; }
+ public int PayloadIssueCount { get; init; }
+ public int SclMismatchCount { get; init; }
+ public bool IsWaveformWindowReady { get; init; }
+ public string LayoutBinding { get; init; } = string.Empty;
+ public string QualitySummary { get; init; } = string.Empty;
+ public string CursorSummary { get; init; } = string.Empty;
+ public string LastSeen { get; init; } = string.Empty;
+}
+
+public sealed record SvSubscriberObservationEvidence
+{
+ public IReadOnlyList InputKinds { get; init; }
+ = Array.Empty();
+ public SvObservationInputKind LastInputKind { get; init; }
+ public bool IsBoundToScl { get; init; }
+ public string ControlBlockReference { get; init; } = string.Empty;
+ public int WindowFrames { get; init; }
+ public int WindowSamples { get; init; }
+ public double WindowDurationSeconds { get; init; }
+ public DateTimeOffset? FirstTimestamp { get; init; }
+ public DateTimeOffset? LastTimestamp { get; init; }
+ public SvObservedStreamFacts Facts { get; init; } = new();
+ public IReadOnlyDictionary FactProvenance { get; init; }
+ = new Dictionary(StringComparer.Ordinal);
+ public SvProfileDetectionResult? ProfileDetection { get; init; }
+ public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; }
+ public SvConfigurationComparisonResult? ConfigurationComparison { get; init; }
+ public IReadOnlyList Diagnostics { get; init; }
+ = Array.Empty();
+}
+
+public sealed record SvSubscriberPhasorEvidence
+{
+ public string Channel { get; init; } = string.Empty;
+ public string Kind { get; init; } = string.Empty;
+ public double Rms { get; init; }
+ public double Peak { get; init; }
+ public double AngleDegrees { get; init; }
+}
+
+public static class SvSubscriberEvidenceReportSerializer
+{
+ private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
+
+ public static string ToJson(SvSubscriberEvidenceReport report)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ report.Validate();
+ return JsonSerializer.Serialize(report, JsonOptions);
+ }
+
+ public static SvSubscriberEvidenceReport FromJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ throw new ArgumentException("SV report JSON cannot be empty.", nameof(json));
+
+ var report = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidDataException("SV report JSON did not contain a report document.");
+ report.Validate();
+ return report;
+ }
+
+ public static string ToMarkdown(SvSubscriberEvidenceReport report)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ report.Validate();
+
+ var builder = new StringBuilder();
+ builder.AppendLine("# ARSVIN Subscriber Evidence Report");
+ builder.AppendLine();
+ builder.AppendLine("> Receiver-side engineering evidence. This document is not a formal IEC 61850 conformance certificate.");
+ builder.AppendLine();
+ builder.AppendLine("## Report metadata");
+ builder.AppendLine();
+ AppendKeyValueTable(builder,
+ [
+ ("Schema", report.SchemaVersion),
+ ("Generated", Timestamp(report.GeneratedAt)),
+ ("Product", report.Software.Product),
+ ("Version", report.Software.Version),
+ ("Informational version", report.Software.InformationalVersion),
+ ("Commit", report.Software.Commit),
+ ("Repository", report.Software.Repository),
+ ("Capture source", report.Capture.Source),
+ ("SCL", Empty(report.Capture.SclPath, "not loaded")),
+ ("Adapter", Empty(report.Capture.Adapter)),
+ ("Filter", Empty(report.Capture.Filter, "none")),
+ ("Capture started", Timestamp(report.Capture.StartedAt)),
+ ("Capture ended", Timestamp(report.Capture.EndedAt)),
+ ("Capture duration", Number(report.Capture.DurationSeconds, "0.###") + " s")
+ ]);
+
+ builder.AppendLine("## Summary");
+ builder.AppendLine();
+ AppendKeyValueTable(builder,
+ [
+ ("Health", report.Summary.Health),
+ ("Raw frames", report.Capture.RawFrames.ToString("N0", CultureInfo.InvariantCulture)),
+ ("SV frames", report.Capture.SvFrames.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Streams", report.Summary.StreamCount.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Runtime issues", report.Summary.RuntimeIssueCount.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Configuration findings", report.Summary.ConfigurationFindingCount.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Parse errors", report.Capture.ParseErrors.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Dropped by filter", report.Capture.DroppedByFilter.ToString("N0", CultureInfo.InvariantCulture))
+ ]);
+
+ builder.AppendLine("## Streams");
+ builder.AppendLine();
+ builder.AppendLine("| Health | APPID | svID | Profile | Confidence | SCL match | Window | Input |" );
+ builder.AppendLine("|---|---:|---|---|---|---|---:|---|");
+ foreach (var stream in report.Streams)
+ {
+ var profile = stream.Observation.ProfileDetection;
+ var comparison = stream.Observation.ConfigurationComparison;
+ builder.Append("| ").Append(Cell(stream.Health))
+ .Append(" | ").Append(Hex(stream.Identity.AppId))
+ .Append(" | ").Append(Cell(stream.Identity.SvId))
+ .Append(" | ").Append(Cell(profile?.Profile.DisplayName ?? "Unknown"))
+ .Append(" | ").Append(Cell(profile?.Confidence.ToString() ?? "Unknown"))
+ .Append(" | ").Append(Cell(comparison?.Summary ?? "Not configured"))
+ .Append(" | ").Append(stream.Observation.WindowSamples.ToString("N0", CultureInfo.InvariantCulture))
+ .Append(" samples | ").Append(Cell(InputText(stream.Observation.InputKinds)))
+ .AppendLine(" |");
+ }
+ builder.AppendLine();
+
+ foreach (var stream in report.Streams)
+ AppendStream(builder, stream);
+
+ return builder.ToString();
+ }
+
+ private static void AppendStream(StringBuilder builder, SvSubscriberStreamEvidence stream)
+ {
+ builder.Append("## ").Append(Hex(stream.Identity.AppId)).Append(" — ")
+ .AppendLine(Heading(stream.Identity.SvId));
+ builder.AppendLine();
+ AppendKeyValueTable(builder,
+ [
+ ("Stream key", stream.Key),
+ ("Health", stream.Health),
+ ("Health detail", stream.HealthDetail),
+ ("Source MAC", stream.Identity.SourceMac),
+ ("Destination MAC", stream.Identity.DestinationMac),
+ ("VLAN", stream.Identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "untagged"),
+ ("VLAN priority", Value(stream.Identity.VlanPriority)),
+ ("Dataset", stream.Identity.DataSetReference),
+ ("confRev", Value(stream.Identity.ConfigurationRevision)),
+ ("nofASDU", stream.Identity.AsduPerFrame.ToString(CultureInfo.InvariantCulture)),
+ ("Last smpCnt", Value(stream.Identity.LastSampleCount)),
+ ("Declared sample rate", Value(stream.Identity.DeclaredSampleRate)),
+ ("Declared sample mode", Value(stream.Identity.DeclaredSampleMode)),
+ ("smpSynch", Value(stream.Identity.SampleSynchronization)),
+ ("SCL binding", stream.Observation.IsBoundToScl ? Empty(stream.Observation.ControlBlockReference) : "not bound")
+ ]);
+
+ builder.AppendLine("### Observation window");
+ builder.AppendLine();
+ AppendKeyValueTable(builder,
+ [
+ ("Input kinds", InputText(stream.Observation.InputKinds)),
+ ("Last input kind", stream.Observation.LastInputKind.ToString()),
+ ("First timestamp", Timestamp(stream.Observation.FirstTimestamp)),
+ ("Last timestamp", Timestamp(stream.Observation.LastTimestamp)),
+ ("Duration", Number(stream.Observation.WindowDurationSeconds, "0.###") + " s"),
+ ("Frames", stream.Observation.WindowFrames.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Samples", stream.Observation.WindowSamples.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Observed fps", Number(stream.Observation.Facts.ObservedFramesPerSecond, "0.###")),
+ ("Observed samples/s", Number(stream.Observation.Facts.ObservedSamplesPerSecond, "0.###")),
+ ("Observed counter wrap", Value(stream.Observation.Facts.ObservedCounterWrap))
+ ]);
+
+ builder.AppendLine("### Runtime integrity");
+ builder.AppendLine();
+ AppendKeyValueTable(builder,
+ [
+ ("Total frames", stream.Runtime.FrameCount.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Total ASDUs", stream.Runtime.AsduCount.ToString("N0", CultureInfo.InvariantCulture)),
+ ("Actual fps", Number(stream.Runtime.ActualFramesPerSecond, "0.###")),
+ ("Average frame gap", Number(stream.Runtime.AverageFrameGapMilliseconds, "0.###") + " ms"),
+ ("Maximum frame gap", Number(stream.Runtime.MaximumFrameGapMilliseconds, "0.###") + " ms"),
+ ("Sequence gaps", stream.Runtime.SequenceGapCount.ToString(CultureInfo.InvariantCulture)),
+ ("Duplicates", stream.Runtime.DuplicateCount.ToString(CultureInfo.InvariantCulture)),
+ ("Out-of-order", stream.Runtime.OutOfOrderCount.ToString(CultureInfo.InvariantCulture)),
+ ("Payload issues", stream.Runtime.PayloadIssueCount.ToString(CultureInfo.InvariantCulture)),
+ ("SCL mismatches", stream.Runtime.SclMismatchCount.ToString(CultureInfo.InvariantCulture)),
+ ("Waveform window ready", stream.Runtime.IsWaveformWindowReady ? "yes" : "no"),
+ ("Layout binding", stream.Runtime.LayoutBinding),
+ ("Quality", stream.Runtime.QualitySummary),
+ ("Cursor", stream.Runtime.CursorSummary),
+ ("Last seen", stream.Runtime.LastSeen)
+ ]);
+
+ AppendObservedFacts(builder, stream.Observation.Facts);
+ AppendExpectedConfiguration(builder, stream.Observation.ExpectedConfiguration);
+ AppendConfigurationFindings(builder, stream.Observation.ConfigurationComparison);
+ AppendProfileEvidence(builder, stream.Observation.ProfileDetection);
+ AppendPhasors(builder, stream.Phasors);
+ AppendDiagnostics(builder, stream.Diagnostics, stream.Observation.Diagnostics);
+ }
+
+ private static void AppendObservedFacts(StringBuilder builder, SvObservedStreamFacts facts)
+ {
+ builder.AppendLine("### Observed facts and provenance");
+ builder.AppendLine();
+ builder.AppendLine("| Fact | Value | Source |");
+ builder.AppendLine("|---|---|---|");
+ AppendFact(builder, facts, nameof(facts.EtherType), "EtherType", facts.EtherType.HasValue ? Hex(facts.EtherType.Value) : "unknown");
+ AppendFact(builder, facts, nameof(facts.AppId), "APPID", facts.AppId.HasValue ? Hex(facts.AppId.Value) : "unknown");
+ AppendFact(builder, facts, nameof(facts.DestinationMac), "Destination MAC", facts.DestinationMac);
+ AppendFact(builder, facts, nameof(facts.VlanId), "VLAN ID", Value(facts.VlanId));
+ AppendFact(builder, facts, nameof(facts.VlanPriority), "VLAN priority", Value(facts.VlanPriority));
+ AppendFact(builder, facts, nameof(facts.SvId), "svID", facts.SvId);
+ AppendFact(builder, facts, nameof(facts.DataSetReference), "Dataset reference", facts.DataSetReference);
+ AppendFact(builder, facts, nameof(facts.ConfigurationRevision), "confRev", Value(facts.ConfigurationRevision));
+ AppendFact(builder, facts, nameof(facts.AsduPerFrame), "ASDU per frame", Value(facts.AsduPerFrame));
+ AppendFact(builder, facts, nameof(facts.PayloadBytesPerAsdu), "Payload bytes per ASDU", Value(facts.PayloadBytesPerAsdu));
+ AppendFact(builder, facts, nameof(facts.DeclaredSampleRate), "Declared sample rate", Value(facts.DeclaredSampleRate));
+ AppendFact(builder, facts, nameof(facts.DeclaredSampleMode), "Declared sample mode", Value(facts.DeclaredSampleMode));
+ AppendFact(builder, facts, nameof(facts.ObservedFramesPerSecond), "Observed frames/s", Number(facts.ObservedFramesPerSecond, "0.###"));
+ AppendFact(builder, facts, nameof(facts.ObservedSamplesPerSecond), "Observed samples/s", Number(facts.ObservedSamplesPerSecond, "0.###"));
+ AppendFact(builder, facts, nameof(facts.ObservedCounterWrap), "Observed counter wrap", Value(facts.ObservedCounterWrap));
+ AppendFact(builder, facts, nameof(facts.NominalFrequencyHz), "Nominal frequency", Number(facts.NominalFrequencyHz, "0.###"));
+ AppendFact(builder, facts, nameof(facts.DataSetSignature), "Dataset signature", Signature(facts.DataSetSignature));
+ var transitions = facts.CounterTransitions;
+ AppendFact(builder, facts, nameof(facts.CounterTransitions), "Counter transitions",
+ $"sequential {transitions.SequentialCount}, gaps {transitions.GapCount}, duplicates {transitions.DuplicateCount}, out-of-order/reset {transitions.OutOfOrderOrResetCount}, wraps {transitions.ConfirmedWrapCount}");
+ builder.AppendLine();
+ }
+
+ private static void AppendExpectedConfiguration(StringBuilder builder, SvExpectedStreamConfiguration? expected)
+ {
+ builder.AppendLine("### Expected SCL configuration");
+ builder.AppendLine();
+ if (expected is null)
+ {
+ builder.AppendLine("- Not configured.");
+ builder.AppendLine();
+ return;
+ }
+
+ AppendKeyValueTable(builder,
+ [
+ ("EtherType", expected.EtherType.HasValue ? Hex(expected.EtherType.Value) : "unknown"),
+ ("APPID", expected.AppId.HasValue ? Hex(expected.AppId.Value) : "unknown"),
+ ("Destination MAC", expected.DestinationMac),
+ ("VLAN ID", Value(expected.VlanId)),
+ ("VLAN priority", Value(expected.VlanPriority)),
+ ("svID", expected.SvId),
+ ("Dataset", expected.DataSetReference),
+ ("confRev", Value(expected.ConfigurationRevision)),
+ ("ASDU per frame", Value(expected.AsduPerFrame)),
+ ("Payload bytes per ASDU", Value(expected.PayloadBytesPerAsdu)),
+ ("Declared sample rate", Value(expected.DeclaredSampleRate)),
+ ("Declared sample mode", Value(expected.DeclaredSampleMode)),
+ ("Dataset signature", Signature(expected.DataSetSignature))
+ ]);
+ }
+
+ private static void AppendConfigurationFindings(StringBuilder builder, SvConfigurationComparisonResult? comparison)
+ {
+ builder.AppendLine("### Configuration comparison");
+ builder.AppendLine();
+ if (comparison is null)
+ {
+ builder.AppendLine("- Not configured.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.Append("- Mode: **").Append(comparison.Mode).AppendLine("**");
+ builder.Append("- Result: **").Append(comparison.Summary).AppendLine("**");
+ builder.AppendLine();
+ if (comparison.Findings.Count == 0)
+ {
+ builder.AppendLine("- No differences found.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.AppendLine("| Severity | Code | Field | Expected | Observed | Message |");
+ builder.AppendLine("|---|---|---|---|---|---|");
+ foreach (var finding in comparison.Findings)
+ {
+ builder.Append("| ").Append(Cell(finding.Severity.ToString()))
+ .Append(" | ").Append(Cell(finding.Code))
+ .Append(" | ").Append(Cell(finding.Field))
+ .Append(" | ").Append(Cell(finding.Expected))
+ .Append(" | ").Append(Cell(finding.Observed))
+ .Append(" | ").Append(Cell(finding.Message)).AppendLine(" |");
+ }
+ builder.AppendLine();
+ }
+
+ private static void AppendProfileEvidence(StringBuilder builder, SvProfileDetectionResult? detection)
+ {
+ builder.AppendLine("### Profile detection evidence");
+ builder.AppendLine();
+ if (detection is null)
+ {
+ builder.AppendLine("- No profile result.");
+ builder.AppendLine();
+ return;
+ }
+
+ AppendKeyValueTable(builder,
+ [
+ ("Profile", detection.Profile.DisplayName),
+ ("Profile ID", detection.Profile.Id),
+ ("Family", detection.Profile.Family),
+ ("Confidence", detection.Confidence.ToString()),
+ ("Raw confidence", detection.RawConfidence.ToString()),
+ ("Score", Number(detection.ScorePercent, "0.###") + "%"),
+ ("Matched weight", detection.MatchedWeight.ToString(CultureInfo.InvariantCulture)),
+ ("Conflict weight", detection.ConflictWeight.ToString(CultureInfo.InvariantCulture)),
+ ("Evaluated weight", detection.EvaluatedWeight.ToString(CultureInfo.InvariantCulture)),
+ ("Evidence status", detection.Profile.EvidenceStatus.ToString())
+ ]);
+
+ builder.AppendLine("#### Evidence sources");
+ builder.AppendLine();
+ builder.AppendLine("| Source | Status | Description |");
+ builder.AppendLine("|---|---|---|");
+ foreach (var source in detection.Profile.Sources)
+ {
+ builder.Append("| ").Append(Cell(source.SourceId))
+ .Append(" | ").Append(Cell(source.Status.ToString()))
+ .Append(" | ").Append(Cell(source.Description)).AppendLine(" |");
+ }
+ builder.AppendLine();
+
+ builder.AppendLine("#### Match evidence");
+ builder.AppendLine();
+ if (detection.Evidence.Count == 0)
+ {
+ builder.AppendLine("- No evaluated evidence fields.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.AppendLine("| Field | Outcome | Weight | Expected | Observed | Message |");
+ builder.AppendLine("|---|---|---:|---|---|---|");
+ foreach (var evidence in detection.Evidence)
+ {
+ builder.Append("| ").Append(Cell(evidence.Field))
+ .Append(" | ").Append(Cell(evidence.Outcome.ToString()))
+ .Append(" | ").Append(evidence.Weight.ToString(CultureInfo.InvariantCulture))
+ .Append(" | ").Append(Cell(evidence.Expected))
+ .Append(" | ").Append(Cell(evidence.Observed))
+ .Append(" | ").Append(Cell(evidence.Message)).AppendLine(" |");
+ }
+ builder.AppendLine();
+ }
+
+ private static void AppendPhasors(StringBuilder builder, IReadOnlyList phasors)
+ {
+ builder.AppendLine("### Phasors");
+ builder.AppendLine();
+ if (phasors.Count == 0)
+ {
+ builder.AppendLine("- No complete decoded phasor window.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.AppendLine("| Channel | Kind | RMS | Peak | Angle |");
+ builder.AppendLine("|---|---|---:|---:|---:|");
+ foreach (var phasor in phasors)
+ {
+ builder.Append("| ").Append(Cell(phasor.Channel))
+ .Append(" | ").Append(Cell(phasor.Kind))
+ .Append(" | ").Append(Number(phasor.Rms, "0.###"))
+ .Append(" | ").Append(Number(phasor.Peak, "0.###"))
+ .Append(" | ").Append(Number(phasor.AngleDegrees, "0.###")).AppendLine("° |");
+ }
+ builder.AppendLine();
+ }
+
+ private static void AppendDiagnostics(
+ StringBuilder builder,
+ IReadOnlyList runtimeDiagnostics,
+ IReadOnlyList observationDiagnostics)
+ {
+ builder.AppendLine("### Diagnostics");
+ builder.AppendLine();
+ var diagnostics = runtimeDiagnostics.Concat(observationDiagnostics)
+ .Where(item => !string.IsNullOrWhiteSpace(item))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (diagnostics.Length == 0)
+ {
+ builder.AppendLine("- No diagnostics.");
+ builder.AppendLine();
+ return;
+ }
+
+ foreach (var diagnostic in diagnostics)
+ builder.Append("- ").AppendLine(diagnostic);
+ builder.AppendLine();
+ }
+
+ private static void AppendFact(
+ StringBuilder builder,
+ SvObservedStreamFacts facts,
+ string propertyName,
+ string label,
+ string value)
+ {
+ var source = facts.Provenance.TryGetValue(propertyName, out var factSource)
+ ? factSource.ToString()
+ : SvFactSource.Unknown.ToString();
+ builder.Append("| ").Append(Cell(label))
+ .Append(" | ").Append(Cell(Empty(value, "unknown")))
+ .Append(" | ").Append(Cell(source)).AppendLine(" |");
+ }
+
+ private static void AppendKeyValueTable(StringBuilder builder, IEnumerable<(string Key, string Value)> rows)
+ {
+ builder.AppendLine("| Field | Value |");
+ builder.AppendLine("|---|---|");
+ foreach (var row in rows)
+ builder.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(Empty(row.Value))).AppendLine(" |");
+ builder.AppendLine();
+ }
+
+ private static JsonSerializerOptions CreateJsonOptions()
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true
+ };
+ options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
+ return options;
+ }
+
+ private static string InputText(IReadOnlyList inputKinds)
+ => inputKinds.Count == 0 ? "Unknown" : string.Join(", ", inputKinds);
+
+ private static string Signature(IReadOnlyList signature)
+ => signature.Count == 0
+ ? "unknown"
+ : string.Join(", ", signature.Select(item =>
+ $"{Empty(item.BType, "-")}/{Empty(item.Cdc, "-")}" +
+ (item.IsQuality ? ":quality" : string.Empty) +
+ (item.IsTimestamp ? ":timestamp" : string.Empty)));
+
+ private static string Timestamp(DateTimeOffset? value)
+ => value.HasValue ? Timestamp(value.Value) : "unknown";
+
+ private static string Timestamp(DateTimeOffset value)
+ => value.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture);
+
+ private static string Hex(ushort value)
+ => $"0x{value:X4}";
+
+ private static string Number(double? value, string format)
+ => value.HasValue ? Number(value.Value, format) : "unknown";
+
+ private static string Number(double value, string format)
+ => value.ToString(format, CultureInfo.InvariantCulture);
+
+ private static string Value(T? value) where T : struct
+ => value.HasValue ? Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? "unknown" : "unknown";
+
+ private static string Empty(string? value, string fallback = "-")
+ => string.IsNullOrWhiteSpace(value) ? fallback : value;
+
+ private static string Cell(string? value)
+ => Empty(value).Replace("|", "\\|", StringComparison.Ordinal)
+ .Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal);
+
+ private static string Heading(string? value)
+ => Empty(value, "Unknown stream").Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal);
+}
diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml
index b965643..27d69bb 100644
--- a/src/ARSVIN.Subscriber/MainWindow.xaml
+++ b/src/ARSVIN.Subscriber/MainWindow.xaml
@@ -102,6 +102,7 @@
+
@@ -182,6 +183,8 @@
+
+
@@ -205,7 +208,55 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -213,7 +264,7 @@
-
+
@@ -300,4 +351,4 @@
-
+
\ No newline at end of file
diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml.cs b/src/ARSVIN.Subscriber/MainWindow.xaml.cs
index 58b5712..43bd8ab 100644
--- a/src/ARSVIN.Subscriber/MainWindow.xaml.cs
+++ b/src/ARSVIN.Subscriber/MainWindow.xaml.cs
@@ -1,7 +1,10 @@
+using System.Text.Json;
using System.Windows;
using System.Windows.Data;
+using AR.Iec61850.SampledValues.Reporting;
using ARSVIN.Subscriber.Controls;
using ARSVIN.Subscriber.ViewModels;
+using Microsoft.Win32;
namespace ARSVIN.Subscriber;
@@ -34,6 +37,80 @@ private void AttachLivePlotControls()
PhasorHost.Child = phasor;
}
+ private async void CompareEvidence_Click(object sender, RoutedEventArgs e)
+ {
+ var baselineDialog = new OpenFileDialog
+ {
+ Title = "Select baseline ARSVIN evidence JSON",
+ Filter = "ARSVIN evidence JSON (*.json)|*.json|All files (*.*)|*.*",
+ Multiselect = false
+ };
+ if (baselineDialog.ShowDialog(this) != true)
+ return;
+
+ var candidateDialog = new OpenFileDialog
+ {
+ Title = "Select candidate ARSVIN evidence JSON",
+ Filter = "ARSVIN evidence JSON (*.json)|*.json|All files (*.*)|*.*",
+ Multiselect = false,
+ InitialDirectory = Path.GetDirectoryName(baselineDialog.FileName)
+ };
+ if (candidateDialog.ShowDialog(this) != true)
+ return;
+
+ var saveDialog = new SaveFileDialog
+ {
+ Title = "Save ARSVIN evidence comparison bundle",
+ Filter = "ARSVIN comparison bundle (*.md)|*.md|Markdown comparison (*.md)|*.md|JSON comparison (*.json)|*.json",
+ DefaultExt = ".md",
+ AddExtension = true,
+ FileName = $"arsvin-subscriber-comparison-{DateTime.Now:yyyyMMdd-HHmmss}.md"
+ };
+ if (saveDialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ var baselineJsonTask = File.ReadAllTextAsync(baselineDialog.FileName);
+ var candidateJsonTask = File.ReadAllTextAsync(candidateDialog.FileName);
+ await Task.WhenAll(baselineJsonTask, candidateJsonTask).ConfigureAwait(true);
+
+ var baseline = SvSubscriberEvidenceReportSerializer.FromJson(await baselineJsonTask.ConfigureAwait(true));
+ var candidate = SvSubscriberEvidenceReportSerializer.FromJson(await candidateJsonTask.ConfigureAwait(true));
+ var comparison = new SvSubscriberEvidenceComparator().Compare(
+ baseline,
+ candidate,
+ DateTimeOffset.Now);
+
+ var markdownPath = Path.ChangeExtension(saveDialog.FileName, ".md");
+ var jsonPath = Path.ChangeExtension(saveDialog.FileName, ".json");
+ await Task.WhenAll(
+ File.WriteAllTextAsync(markdownPath, SvSubscriberEvidenceComparisonSerializer.ToMarkdown(comparison)),
+ File.WriteAllTextAsync(jsonPath, SvSubscriberEvidenceComparisonSerializer.ToJson(comparison))).ConfigureAwait(true);
+
+ var status = comparison.Summary.HasRegressions
+ ? $"Review required: {comparison.Summary.WarningChangeCount} warning(s), {comparison.Summary.ErrorChangeCount} error(s)."
+ : "No regression detected.";
+ MessageBox.Show(
+ this,
+ $"Evidence comparison exported:\n{Path.GetFileName(markdownPath)}\n{Path.GetFileName(jsonPath)}\n\n{status}",
+ "ARSVIN Evidence Comparison",
+ MessageBoxButton.OK,
+ comparison.Summary.HasRegressions ? MessageBoxImage.Warning : MessageBoxImage.Information);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or
+ ArgumentException or InvalidDataException or
+ InvalidOperationException or JsonException)
+ {
+ MessageBox.Show(
+ this,
+ $"Evidence comparison failed: {ex.Message}",
+ "ARSVIN Evidence Comparison",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
protected override void OnClosed(EventArgs e)
{
_viewModel.Dispose();
diff --git a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs
index 76a95a7..475ac24 100644
--- a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs
+++ b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs
@@ -43,11 +43,15 @@ public sealed class SvStreamSnapshot
public IReadOnlyList ObservationInputKinds { get; init; }
= Array.Empty();
public int ObservationWindowFrames { get; init; }
+ public int ObservationWindowSamples { get; init; }
+ public double ObservationWindowDurationSeconds { get; init; }
public double? ObservedFramesPerSecond { get; init; }
public double? ObservedSamplesPerSecond { get; init; }
public int? ObservedCounterWrap { get; init; }
+ public bool IsWaveformWindowReady { get; init; }
+ public SvProfileDetectionResult? ProfileDetection { get; init; }
+ public SvConfigurationComparisonResult? ConfigurationComparison { get; init; }
public IReadOnlyList ObservationDiagnostics { get; init; } = Array.Empty();
public IReadOnlyDictionary FactProvenance { get; init; }
= new Dictionary(StringComparer.Ordinal);
}
-
diff --git a/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs
new file mode 100644
index 0000000..5bdeb7d
--- /dev/null
+++ b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs
@@ -0,0 +1,307 @@
+using System.Reflection;
+using AR.Iec61850.SampledValues.Profiles;
+using AR.Iec61850.SampledValues.Reporting;
+using ARSVIN.Subscriber.Models;
+
+namespace ARSVIN.Subscriber.Reporting;
+
+internal sealed record SvSubscriberReportContext
+{
+ public DateTimeOffset GeneratedAt { get; init; }
+ public DateTimeOffset? CaptureStartedAt { get; init; }
+ public string Health { get; init; } = "IDLE";
+ public string SclPath { get; init; } = string.Empty;
+ public string Adapter { get; init; } = string.Empty;
+ public string Filter { get; init; } = string.Empty;
+ public long RawFrames { get; init; }
+ public long SvFrames { get; init; }
+ public long ParseErrors { get; init; }
+ public long DroppedByFilter { get; init; }
+ public IReadOnlyList Streams { get; init; }
+ = Array.Empty();
+ public IReadOnlyDictionary Observations { get; init; }
+ = new Dictionary(StringComparer.Ordinal);
+}
+
+internal static class SvSubscriberReportBuilder
+{
+ private const string RepositoryUrl = "https://github.com/masarray/arsvin";
+
+ public static SvSubscriberEvidenceReport Build(SvSubscriberReportContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ if (context.GeneratedAt == default)
+ throw new ArgumentException("Report context requires a generation timestamp.", nameof(context));
+
+ var streams = context.Streams
+ .OrderBy(stream => stream.AppId)
+ .ThenBy(stream => stream.SvId, StringComparer.Ordinal)
+ .Select(stream => BuildStream(stream, ResolveObservation(context, stream.Key)))
+ .ToArray();
+ var runtimeIssues = streams.Sum(stream =>
+ stream.Runtime.SequenceGapCount +
+ stream.Runtime.DuplicateCount +
+ stream.Runtime.OutOfOrderCount +
+ stream.Runtime.PayloadIssueCount);
+ var configurationFindings = streams.Sum(stream =>
+ stream.Observation.ConfigurationComparison?.Findings.Count ?? 0);
+ var duration = context.CaptureStartedAt.HasValue
+ ? Math.Max(0, (context.GeneratedAt - context.CaptureStartedAt.Value).TotalSeconds)
+ : 0;
+
+ return new SvSubscriberEvidenceReport
+ {
+ GeneratedAt = context.GeneratedAt,
+ Software = ResolveSoftwareEvidence(),
+ Capture = new SvSubscriberCaptureEvidence
+ {
+ Source = ResolveCaptureSource(streams),
+ SclPath = context.SclPath,
+ Adapter = context.Adapter,
+ Filter = context.Filter,
+ StartedAt = context.CaptureStartedAt,
+ EndedAt = context.GeneratedAt,
+ DurationSeconds = duration,
+ RawFrames = context.RawFrames,
+ SvFrames = context.SvFrames,
+ ParseErrors = context.ParseErrors,
+ DroppedByFilter = context.DroppedByFilter
+ },
+ Summary = new SvSubscriberSummaryEvidence
+ {
+ Health = context.Health,
+ StreamCount = streams.Length,
+ RuntimeIssueCount = runtimeIssues,
+ ConfigurationFindingCount = configurationFindings
+ },
+ Streams = streams
+ };
+ }
+
+ private static SvSubscriberStreamEvidence BuildStream(
+ SvStreamSnapshot stream,
+ SvStreamObservationSnapshot? observation)
+ {
+ var facts = observation?.Facts ?? BuildFallbackFacts(stream);
+ var inputKinds = observation?.InputKinds ?? stream.ObservationInputKinds;
+ var observationDiagnostics = observation?.Diagnostics ?? stream.ObservationDiagnostics;
+ var profileDetection = observation?.ProfileDetection ?? stream.ProfileDetection;
+ var configurationComparison = observation?.ConfigurationComparison ?? stream.ConfigurationComparison;
+ var health = ResolveHealth(stream, observation, configurationComparison);
+ var healthDetail = ResolveHealthDetail(stream, observation, configurationComparison, health);
+ var diagnostics = stream.Diagnostics.Concat(observationDiagnostics)
+ .Where(item => !string.IsNullOrWhiteSpace(item))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ return new SvSubscriberStreamEvidence
+ {
+ Key = stream.Key,
+ Health = health,
+ HealthDetail = healthDetail,
+ Identity = new SvSubscriberStreamIdentityEvidence
+ {
+ AppId = stream.AppId,
+ SourceMac = stream.Source,
+ DestinationMac = stream.Destination,
+ VlanId = stream.VlanId,
+ VlanPriority = stream.VlanPriority,
+ SvId = stream.SvId,
+ DataSetReference = stream.DataSet,
+ ConfigurationRevision = stream.ConfRev,
+ AsduPerFrame = stream.NofAsdu,
+ LastSampleCount = stream.LastSmpCnt,
+ DeclaredSampleRate = stream.SampleRate,
+ DeclaredSampleMode = stream.SampleMode,
+ SampleSynchronization = stream.SmpSynch
+ },
+ Runtime = new SvSubscriberRuntimeEvidence
+ {
+ FrameCount = stream.FrameCount,
+ AsduCount = stream.AsduCount,
+ ActualFramesPerSecond = stream.ActualFps,
+ AverageFrameGapMilliseconds = stream.AverageFrameGapMilliseconds,
+ MaximumFrameGapMilliseconds = stream.MaxFrameGapMilliseconds,
+ SequenceGapCount = stream.SequenceGapCount,
+ DuplicateCount = stream.DuplicateCount,
+ OutOfOrderCount = stream.OutOfOrderCount,
+ PayloadIssueCount = stream.PayloadIssueCount,
+ SclMismatchCount = stream.SclMismatchCount,
+ IsWaveformWindowReady = stream.IsWaveformWindowReady,
+ LayoutBinding = stream.LayoutBinding,
+ QualitySummary = stream.QualitySummary,
+ CursorSummary = stream.CursorSummary,
+ LastSeen = stream.LastSeen
+ },
+ Observation = new SvSubscriberObservationEvidence
+ {
+ InputKinds = inputKinds,
+ LastInputKind = observation?.LastInputKind ?? inputKinds.LastOrDefault(),
+ IsBoundToScl = observation?.IsBoundToScl ?? stream.IsBoundToScl,
+ ControlBlockReference = observation?.ControlBlockReference ?? stream.ControlBlockReference,
+ WindowFrames = facts.ObservationCount > 0
+ ? facts.ObservationCount
+ : stream.ObservationWindowFrames,
+ WindowSamples = ResolveWindowSamples(facts, stream.ObservationWindowSamples),
+ WindowDurationSeconds = ResolveWindowDuration(facts, stream.ObservationWindowDurationSeconds),
+ FirstTimestamp = facts.FirstTimestamp,
+ LastTimestamp = facts.LastTimestamp,
+ Facts = facts,
+ FactProvenance = facts.Provenance.Count > 0
+ ? facts.Provenance
+ : stream.FactProvenance,
+ ProfileDetection = profileDetection,
+ ExpectedConfiguration = observation?.ExpectedConfiguration,
+ ConfigurationComparison = configurationComparison,
+ Diagnostics = observationDiagnostics
+ },
+ Phasors = stream.Phasors.Select(phasor => new SvSubscriberPhasorEvidence
+ {
+ Channel = phasor.Channel,
+ Kind = phasor.Kind,
+ Rms = phasor.Rms,
+ Peak = phasor.Peak,
+ AngleDegrees = phasor.AngleDegrees
+ }).ToArray(),
+ Diagnostics = diagnostics
+ };
+ }
+
+ private static SvStreamObservationSnapshot? ResolveObservation(
+ SvSubscriberReportContext context,
+ string key)
+ => context.Observations.TryGetValue(key, out var observation)
+ ? observation
+ : null;
+
+ private static SvObservedStreamFacts BuildFallbackFacts(SvStreamSnapshot stream)
+ => new()
+ {
+ EtherType = 0x88BA,
+ AppId = stream.AppId,
+ DestinationMac = stream.Destination,
+ VlanId = stream.VlanId,
+ VlanPriority = stream.VlanPriority,
+ SvId = stream.SvId,
+ DataSetReference = stream.DataSet,
+ ConfigurationRevision = stream.ConfRev,
+ AsduPerFrame = stream.NofAsdu > 0 ? stream.NofAsdu : null,
+ ObservedFramesPerSecond = stream.ObservedFramesPerSecond,
+ ObservedSamplesPerSecond = stream.ObservedSamplesPerSecond,
+ ObservedCounterWrap = stream.ObservedCounterWrap,
+ DeclaredSampleRate = stream.SampleRate,
+ DeclaredSampleMode = stream.SampleMode,
+ Provenance = stream.FactProvenance,
+ ObservationCount = stream.ObservationWindowFrames,
+ Diagnostics = stream.ObservationDiagnostics
+ };
+
+ private static int ResolveWindowSamples(SvObservedStreamFacts facts, int fallback)
+ {
+ if (facts.ObservationCount > 0)
+ return facts.ObservationCount * Math.Max(1, facts.AsduPerFrame ?? 1);
+ return Math.Max(0, fallback);
+ }
+
+ private static string ResolveHealth(
+ SvStreamSnapshot stream,
+ SvStreamObservationSnapshot? observation,
+ SvConfigurationComparisonResult? comparison)
+ {
+ var hasRuntimeError = stream.PayloadIssueCount > 0 || stream.OutOfOrderCount > 0;
+ var hasRuntimeWarning = stream.SequenceGapCount > 0 || stream.DuplicateCount > 0;
+ var isConfigured = observation?.IsBoundToScl ?? stream.IsBoundToScl;
+ if (hasRuntimeError || comparison?.HasBlockingErrors == true)
+ return "BAD";
+ if (hasRuntimeWarning || comparison?.WarningCount > 0 || !isConfigured)
+ return "WARN";
+ return "GOOD";
+ }
+
+ private static string ResolveHealthDetail(
+ SvStreamSnapshot stream,
+ SvStreamObservationSnapshot? observation,
+ SvConfigurationComparisonResult? comparison,
+ string health)
+ {
+ var blocking = comparison?.Findings
+ .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Error);
+ if (blocking is not null)
+ return blocking.Message;
+ if (stream.PayloadIssueCount > 0 || stream.OutOfOrderCount > 0)
+ return stream.HealthDetail;
+
+ var warning = comparison?.Findings
+ .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Warning);
+ if (warning is not null)
+ return warning.Message;
+ if (health == "GOOD")
+ return "SV stream is stable and matches the configured SCL expectation.";
+ if (observation?.IsBoundToScl != true)
+ return "SV stream is not bound to an SCL expectation.";
+ return stream.HealthDetail;
+ }
+
+ private static double ResolveWindowDuration(SvObservedStreamFacts facts, double fallback)
+ {
+ if (facts.FirstTimestamp.HasValue && facts.LastTimestamp.HasValue)
+ return Math.Max(0, (facts.LastTimestamp.Value - facts.FirstTimestamp.Value).TotalSeconds);
+ return Math.Max(0, fallback);
+ }
+
+ private static string ResolveCaptureSource(IReadOnlyList streams)
+ {
+ var kinds = streams.SelectMany(stream => stream.Observation.InputKinds)
+ .Where(kind => kind != SvObservationInputKind.Unknown)
+ .Distinct()
+ .OrderBy(kind => kind)
+ .ToArray();
+ return kinds.Length switch
+ {
+ 0 => "Unknown",
+ 1 => kinds[0].ToString(),
+ _ => "Mixed: " + string.Join(", ", kinds)
+ };
+ }
+
+ private static SvSubscriberSoftwareEvidence ResolveSoftwareEvidence()
+ {
+ var assembly = typeof(SvSubscriberReportBuilder).Assembly;
+ var informationalVersion = assembly
+ .GetCustomAttribute()?
+ .InformationalVersion ?? string.Empty;
+ var product = assembly.GetCustomAttribute()?.Product;
+ var version = assembly.GetName().Version?.ToString() ?? "unknown";
+
+ return new SvSubscriberSoftwareEvidence
+ {
+ Product = string.IsNullOrWhiteSpace(product) ? "ARSVIN Subscriber" : product,
+ Version = version,
+ InformationalVersion = string.IsNullOrWhiteSpace(informationalVersion)
+ ? version
+ : informationalVersion,
+ Commit = ResolveCommit(informationalVersion),
+ Repository = RepositoryUrl
+ };
+ }
+
+ private static string ResolveCommit(string informationalVersion)
+ {
+ foreach (var environmentVariable in new[] { "ARSVIN_COMMIT_SHA", "GITHUB_SHA" })
+ {
+ var value = Environment.GetEnvironmentVariable(environmentVariable);
+ if (!string.IsNullOrWhiteSpace(value))
+ return value.Trim();
+ }
+
+ var separator = informationalVersion.LastIndexOf('+');
+ if (separator >= 0 && separator + 1 < informationalVersion.Length)
+ {
+ var candidate = informationalVersion[(separator + 1)..].Trim();
+ if (candidate.Length >= 7 && candidate.All(Uri.IsHexDigit))
+ return candidate;
+ }
+
+ return "unknown";
+ }
+}
diff --git a/src/ARSVIN.Subscriber/ViewModels/BulkObservableCollection.cs b/src/ARSVIN.Subscriber/ViewModels/BulkObservableCollection.cs
new file mode 100644
index 0000000..d550e31
--- /dev/null
+++ b/src/ARSVIN.Subscriber/ViewModels/BulkObservableCollection.cs
@@ -0,0 +1,25 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+
+namespace ARSVIN.Subscriber.ViewModels;
+
+public sealed class BulkObservableCollection : ObservableCollection
+{
+ public void ReplaceAll(IEnumerable items)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ var replacement = items.ToArray();
+ if (Items.SequenceEqual(replacement))
+ return;
+
+ Items.Clear();
+ foreach (var item in replacement)
+ Items.Add(item);
+
+ OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
+ OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
+ OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+}
diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs
index 09f9f9e..976565b 100644
--- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs
+++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs
@@ -1,10 +1,14 @@
-using System.Collections.ObjectModel;
+using AR.Iec61850.SampledValues.Profiles;
using ARSVIN.Subscriber.Models;
namespace ARSVIN.Subscriber.ViewModels;
public sealed class SvStreamViewModel : ObservableObject
{
+ private readonly BulkObservableCollection _values = new();
+ private readonly BulkObservableCollection _waveformPoints = new();
+ private readonly BulkObservableCollection _phasors = new();
+ private readonly BulkObservableCollection _evidenceDetails = new();
private string _key = string.Empty;
private string _health = "IDLE";
private string _healthDetail = string.Empty;
@@ -29,10 +33,17 @@ public sealed class SvStreamViewModel : ObservableObject
private string _sourceDestination = string.Empty;
private string _cursorSummary = string.Empty;
private string _qualitySummary = string.Empty;
+ private string _profile = "Unknown";
+ private string _confidence = "Unknown · insufficient evidence";
+ private string _sclMatch = "Not configured";
+ private string _window = "0.0 s · 0 samples";
+ private string _comparisonMode = "Compatible";
+ private string _waveformState = "Waiting for complete two-cycle window";
- public ObservableCollection Values { get; } = new();
- public ObservableCollection WaveformPoints { get; } = new();
- public ObservableCollection Phasors { get; } = new();
+ public IReadOnlyList Values => _values;
+ public IReadOnlyList WaveformPoints => _waveformPoints;
+ public IReadOnlyList Phasors => _phasors;
+ public IReadOnlyList EvidenceDetails => _evidenceDetails;
public string Key { get => _key; set => SetProperty(ref _key, value); }
public string Health { get => _health; set => SetProperty(ref _health, value); }
@@ -58,12 +69,16 @@ public sealed class SvStreamViewModel : ObservableObject
public string SourceDestination { get => _sourceDestination; set => SetProperty(ref _sourceDestination, value); }
public string CursorSummary { get => _cursorSummary; set => SetProperty(ref _cursorSummary, value); }
public string QualitySummary { get => _qualitySummary; set => SetProperty(ref _qualitySummary, value); }
+ public string Profile { get => _profile; set => SetProperty(ref _profile, value); }
+ public string Confidence { get => _confidence; set => SetProperty(ref _confidence, value); }
+ public string SclMatch { get => _sclMatch; set => SetProperty(ref _sclMatch, value); }
+ public string Window { get => _window; set => SetProperty(ref _window, value); }
+ public string ComparisonMode { get => _comparisonMode; set => SetProperty(ref _comparisonMode, value); }
+ public string WaveformState { get => _waveformState; set => SetProperty(ref _waveformState, value); }
- public void Apply(SvStreamSnapshot snapshot)
+ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observation)
{
Key = snapshot.Key;
- Health = snapshot.Health;
- HealthDetail = snapshot.HealthDetail;
AppId = $"0x{snapshot.AppId:X4}";
SvId = string.IsNullOrWhiteSpace(snapshot.SvId) ? "-" : snapshot.SvId;
Source = snapshot.Source;
@@ -81,25 +96,147 @@ public void Apply(SvStreamSnapshot snapshot)
DataSet = string.IsNullOrWhiteSpace(snapshot.DataSet) ? "-" : snapshot.DataSet;
CursorSummary = snapshot.CursorSummary;
QualitySummary = snapshot.QualitySummary;
- var issueTotal = snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + snapshot.PayloadIssueCount + snapshot.SclMismatchCount;
+
+ var comparison = observation?.ConfigurationComparison;
+ var configurationIssues = comparison?.Findings.Count ?? 0;
+ var issueTotal = snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + snapshot.PayloadIssueCount + configurationIssues;
Issues = issueTotal == 0
? "0"
- : $"{issueTotal} (gap {snapshot.SequenceGapCount}, dup {snapshot.DuplicateCount}, order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}, SCL {snapshot.SclMismatchCount})";
- Bound = snapshot.IsBoundToScl
- ? $"SCL: {snapshot.ControlBlockReference}"
+ : $"{issueTotal} (gap {snapshot.SequenceGapCount}, dup {snapshot.DuplicateCount}, order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}, SCL {configurationIssues})";
+
+ var hasBlockingConfigurationError = comparison?.HasBlockingErrors == true;
+ var hasConfigurationWarning = comparison?.WarningCount > 0;
+ var hasRuntimeError = snapshot.PayloadIssueCount > 0 || snapshot.OutOfOrderCount > 0;
+ var hasRuntimeWarning = snapshot.SequenceGapCount > 0 || snapshot.DuplicateCount > 0;
+ var isConfigured = observation?.IsBoundToScl == true;
+ Health = hasRuntimeError || hasBlockingConfigurationError
+ ? "BAD"
+ : hasRuntimeWarning || hasConfigurationWarning || !isConfigured
+ ? "WARN"
+ : "GOOD";
+ HealthDetail = ResolveHealthDetail(snapshot, observation, Health);
+
+ Bound = isConfigured
+ ? $"SCL: {observation!.ControlBlockReference}"
: string.IsNullOrWhiteSpace(snapshot.LayoutBinding) ? "Unbound" : snapshot.LayoutBinding;
LastSeen = snapshot.LastSeen;
- Summary = string.Join(" • ", snapshot.Diagnostics.Take(3));
+ Summary = string.Join(" • ", observation?.Diagnostics.Take(3) ?? snapshot.Diagnostics.Take(3));
+
+ var detection = observation?.ProfileDetection;
+ Profile = detection?.Profile.Family ?? "Unknown profile";
+ Confidence = detection is null
+ ? "Unknown · insufficient evidence"
+ : $"{detection.Confidence} · {ConfidenceReason(detection)}";
+ SclMatch = observation?.ConfigurationMatchSummary ?? "Not configured";
+ ComparisonMode = comparison?.Mode.ToString() ?? "Compatible";
+
+ var duration = ResolveObservationDuration(observation?.Facts);
+ var samples = ResolveObservationSamples(observation?.Facts);
+ Window = $"{duration:0.0} s · {samples:N0} samples";
+
+ _values.ReplaceAll(snapshot.Values.Take(64));
+ UpdateStableVisuals(snapshot);
+ _evidenceDetails.ReplaceAll(BuildEvidenceDetails(observation));
+ }
+
+ private void UpdateStableVisuals(SvStreamSnapshot snapshot)
+ {
+ var expectedPoints = ResolveTwoCyclePointCount(snapshot.SampleRate);
+ var fullWindow = snapshot.WaveformPoints.Count >= expectedPoints;
+ if (fullWindow)
+ {
+ _waveformPoints.ReplaceAll(snapshot.WaveformPoints.TakeLast(expectedPoints));
+ _phasors.ReplaceAll(snapshot.Phasors);
+ WaveformState = $"2 cycles locked · {expectedPoints:N0} points";
+ return;
+ }
+
+ if (_waveformPoints.Count == 0)
+ _phasors.ReplaceAll(Array.Empty());
+ WaveformState = $"Filling full window · {snapshot.WaveformPoints.Count:N0}/{expectedPoints:N0} points";
+ }
+
+ private static int ResolveTwoCyclePointCount(ushort? sampleRate)
+ {
+ var pointsPerCycle = sampleRate is > 1000
+ ? (int)Math.Round(sampleRate.Value / 50.0)
+ : sampleRate ?? 80;
+ return Math.Clamp(pointsPerCycle * 2, 32, 512);
+ }
- Replace(Values, snapshot.Values.Take(64));
- Replace(WaveformPoints, snapshot.WaveformPoints);
- Replace(Phasors, snapshot.Phasors);
+ private static double ResolveObservationDuration(SvObservedStreamFacts? facts)
+ {
+ if (facts?.FirstTimestamp is not { } first || facts.LastTimestamp is not { } last)
+ return 0;
+ return Math.Max(0, (last - first).TotalSeconds);
+ }
+
+ private static int ResolveObservationSamples(SvObservedStreamFacts? facts)
+ {
+ if (facts is null)
+ return 0;
+ return facts.ObservationCount * Math.Max(1, facts.AsduPerFrame ?? 1);
+ }
+
+ private static string ConfidenceReason(SvProfileDetectionResult detection)
+ => detection.Confidence switch
+ {
+ SvProfileConfidence.Unknown => "insufficient evidence",
+ SvProfileConfidence.Possible => "partial evidence",
+ SvProfileConfidence.Likely => "strong engineering evidence",
+ SvProfileConfidence.Confirmed => "mature matching evidence",
+ SvProfileConfidence.Conflict => "conflicting evidence",
+ _ => "insufficient evidence"
+ };
+
+ private static string ResolveHealthDetail(
+ SvStreamSnapshot snapshot,
+ SvStreamObservationSnapshot? observation,
+ string health)
+ {
+ var blocking = observation?.ConfigurationComparison?.Findings
+ .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Error);
+ if (blocking is not null)
+ return blocking.Message;
+
+ if (snapshot.PayloadIssueCount > 0 || snapshot.OutOfOrderCount > 0)
+ return snapshot.HealthDetail;
+
+ var warning = observation?.ConfigurationComparison?.Findings
+ .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Warning);
+ if (warning is not null)
+ return warning.Message;
+
+ if (health == "GOOD")
+ return "SV stream is stable and matches the configured SCL expectation.";
+ return snapshot.HealthDetail;
}
- private static void Replace(ObservableCollection collection, IEnumerable items)
+ private static IReadOnlyList BuildEvidenceDetails(SvStreamObservationSnapshot? observation)
{
- collection.Clear();
- foreach (var item in items)
- collection.Add(item);
+ if (observation is null)
+ return ["No observation evidence is available yet."];
+
+ var lines = new List();
+ if (observation.ProfileDetection is { } detection)
+ {
+ lines.Add($"Profile: {detection.Profile.DisplayName} · confidence {detection.Confidence} · score {detection.ScorePercent:0.#}% · evidence {detection.MatchedWeight}/{detection.EvaluatedWeight}.");
+ lines.AddRange(detection.Evidence.Select(item =>
+ $"PROFILE · {item.Field} · {item.Outcome} · expected {item.Expected} · observed {item.Observed} · {item.Message}"));
+ }
+
+ if (observation.ConfigurationComparison is { } comparison)
+ {
+ lines.Add($"SCL comparison: {comparison.Summary} · mode {comparison.Mode}.");
+ lines.AddRange(comparison.Findings.Select(item =>
+ $"SCL · {item.Severity} · {item.Field} · expected {item.Expected} · observed {item.Observed} · {item.Message}"));
+ }
+ else
+ {
+ lines.Add("SCL comparison: not configured.");
+ }
+
+ lines.AddRange(observation.Diagnostics.Select(item => $"OBSERVATION · {item}"));
+ return lines.Distinct(StringComparer.Ordinal).ToArray();
}
}
diff --git a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs
index 16bbcfc..7dc966a 100644
--- a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs
+++ b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs
@@ -4,10 +4,12 @@
using System.Windows.Threading;
using AR.Iec61850.SampledValues;
using AR.Iec61850.SampledValues.Profiles;
+using AR.Iec61850.SampledValues.Reporting;
using AR.Iec61850.Scl;
using AR.Iec61850.Transports;
using AR.Iec61850.Transports.Npcap;
using ARSVIN.Subscriber.Models;
+using ARSVIN.Subscriber.Reporting;
using Microsoft.Win32;
namespace ARSVIN.Subscriber.ViewModels;
@@ -15,6 +17,7 @@ namespace ARSVIN.Subscriber.ViewModels;
public sealed class SvSubscriberViewModel : ObservableObject, IDisposable
{
private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _latestObservations = new(StringComparer.Ordinal);
private readonly SvStreamObservationManager _observationManager = new();
private readonly Dictionary _streamRows = new(StringComparer.OrdinalIgnoreCase);
private readonly DispatcherTimer _uiTimer;
@@ -60,7 +63,7 @@ public SvSubscriberViewModel()
public ObservableCollection Adapters { get; } = new();
public ObservableCollection Streams { get; } = new();
- public ObservableCollection SelectedValues { get; } = new();
+ public BulkObservableCollection SelectedValues { get; } = new();
public RelayCommand RefreshAdaptersCommand { get; }
public AsyncRelayCommand OpenSclCommand { get; }
@@ -181,7 +184,6 @@ private async Task OpenSclAsync()
}
}
-
private async Task OpenCaptureFileAsync()
{
var dialog = new OpenFileDialog
@@ -247,8 +249,13 @@ private void ObserveFrame(
}
var key = observation.Key.Id;
+ _latestObservations[key] = observation;
var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key));
- runtime.Observe(timestamp, frame, profile, observation);
+ runtime.Observe(
+ timestamp,
+ frame,
+ observation.IsBoundToScl ? profile : null,
+ observation);
}
private void ToggleCapture()
@@ -340,11 +347,34 @@ private bool PassesUserFilter(SampledValuesFrame frame)
if (asdu is null || _sclProfiles.Count == 0)
return null;
- return _sclProfiles.FirstOrDefault(profile =>
- profile.AppId == frame.AppId &&
- string.Equals(profile.Stream.SvId, asdu.SvId, StringComparison.OrdinalIgnoreCase))
- ?? _sclProfiles.FirstOrDefault(profile => profile.AppId == frame.AppId)
- ?? _sclProfiles.FirstOrDefault(profile => string.Equals(profile.Stream.SvId, asdu.SvId, StringComparison.OrdinalIgnoreCase));
+ 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)
+ .ToArray();
+ if (addressCandidates.Length == 0)
+ 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;
}
private void RefreshUiSnapshots()
@@ -360,7 +390,8 @@ private void RefreshUiSnapshots()
SelectedStream ??= row;
}
- row.Apply(snapshot);
+ _latestObservations.TryGetValue(snapshot.Key, out var observation);
+ row.Apply(snapshot, observation);
}
var keys = snapshots.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -378,12 +409,7 @@ private void RefreshUiSnapshots()
private void RefreshSelectedValues()
{
- SelectedValues.Clear();
- if (SelectedStream is null)
- return;
-
- foreach (var value in SelectedStream.Values)
- SelectedValues.Add(value);
+ SelectedValues.ReplaceAll(SelectedStream?.Values ?? Array.Empty());
}
private void UpdateGlobalCards(IReadOnlyList snapshots)
@@ -392,7 +418,12 @@ private void UpdateGlobalCards(IReadOnlyList snapshots)
var sv = Interlocked.Read(ref _svFrames);
var parse = Interlocked.Read(ref _parseErrors);
var dropped = Interlocked.Read(ref _droppedByFilter);
- var issues = snapshots.Sum(x => x.SequenceGapCount + x.DuplicateCount + x.OutOfOrderCount + x.PayloadIssueCount + x.SclMismatchCount) + parse;
+ 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 issues = runtimeIssues + configurationIssues + parse;
var duration = _captureStarted.HasValue ? DateTimeOffset.Now - _captureStarted.Value : TimeSpan.Zero;
var fps = duration.TotalSeconds > 0.001 ? sv / duration.TotalSeconds : 0;
@@ -405,12 +436,14 @@ private void UpdateGlobalCards(IReadOnlyList snapshots)
if (!IsCapturing && snapshots.Count == 0)
HealthText = "IDLE";
- else if (issues > 0)
- HealthText = snapshots.Any(x => x.Health == "BAD") || parse > 0 ? "BAD" : "WARN";
+ 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 = snapshots.All(x => x.IsBoundToScl) ? "GOOD" : "WARN";
+ HealthText = "GOOD";
if (dropped > 0 && IsCapturing)
StatusText = $"Listening. User filter dropped {dropped:N0} SV frame(s).";
@@ -420,7 +453,7 @@ private void Clear()
{
ClearRuntimeOnly();
Streams.Clear();
- SelectedValues.Clear();
+ SelectedValues.ReplaceAll(Array.Empty());
_streamRows.Clear();
SelectedStream = null;
HealthText = "IDLE";
@@ -431,10 +464,11 @@ private void Clear()
private void ClearRuntimeOnly()
{
_runtimeStreams.Clear();
+ _latestObservations.Clear();
_observationManager.Clear();
_streamRows.Clear();
Streams.Clear();
- SelectedValues.Clear();
+ SelectedValues.ReplaceAll(Array.Empty());
SelectedStream = null;
Interlocked.Exchange(ref _rawFrames, 0);
Interlocked.Exchange(ref _svFrames, 0);
@@ -449,88 +483,57 @@ private async Task ExportReportAsync()
{
var dialog = new SaveFileDialog
{
- Title = "Export SV subscriber verification report",
- Filter = "Markdown report (*.md)|*.md|All files (*.*)|*.*",
- FileName = $"arsvin-subscriber-report-{DateTime.Now:yyyyMMdd-HHmmss}.md"
+ Title = "Export SV subscriber evidence bundle",
+ Filter = "ARSVIN evidence bundle (*.md)|*.md|Markdown report (*.md)|*.md|JSON evidence (*.json)|*.json",
+ DefaultExt = ".md",
+ AddExtension = true,
+ FileName = $"arsvin-subscriber-evidence-{DateTime.Now:yyyyMMdd-HHmmss}.md"
};
if (dialog.ShowDialog() != true)
return;
- var snapshots = _runtimeStreams.Values.Select(x => x.Snapshot()).OrderBy(x => x.AppId).ThenBy(x => x.SvId).ToArray();
- var lines = new List
+ try
{
- "# ARSVIN Subscriber Verification Report",
- string.Empty,
- $"Generated: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}",
- $"SCL: {(string.IsNullOrWhiteSpace(_selectedSclPath) ? "not loaded" : _selectedSclPath)}",
- $"Adapter: {SelectedAdapter?.DisplayName ?? "-"}",
- $"Filter: {(string.IsNullOrWhiteSpace(FilterText) ? "none" : FilterText)}",
- string.Empty,
- "> This report is receiver-side evidence from ARSVIN Subscriber. It is not a formal IEC 61850 conformance certificate.",
- string.Empty,
- "## Summary",
- string.Empty,
- $"- Raw frames: {Interlocked.Read(ref _rawFrames):N0}",
- $"- SV frames: {Interlocked.Read(ref _svFrames):N0}",
- $"- Streams: {snapshots.Length:N0}",
- $"- Health: {HealthText}",
- string.Empty,
- "## Streams",
- string.Empty,
- "| Health | APPID | svID | Bound | nofASDU | fps | smpCnt | Quality | Issues |",
- "|---|---:|---|---|---:|---:|---:|---|---:|"
- };
+ 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 report = SvSubscriberReportBuilder.Build(new SvSubscriberReportContext
+ {
+ GeneratedAt = generatedAt,
+ CaptureStartedAt = _captureStarted,
+ Health = HealthText,
+ SclPath = _selectedSclPath,
+ Adapter = SelectedAdapter?.DisplayName ?? string.Empty,
+ Filter = string.IsNullOrWhiteSpace(FilterText) ? string.Empty : FilterText,
+ RawFrames = Interlocked.Read(ref _rawFrames),
+ SvFrames = Interlocked.Read(ref _svFrames),
+ ParseErrors = Interlocked.Read(ref _parseErrors),
+ DroppedByFilter = Interlocked.Read(ref _droppedByFilter),
+ Streams = snapshots,
+ Observations = observations
+ });
- foreach (var stream in snapshots)
- {
- var issues = stream.SequenceGapCount + stream.DuplicateCount + stream.OutOfOrderCount + stream.PayloadIssueCount + stream.SclMismatchCount;
- lines.Add($"| {stream.Health} | 0x{stream.AppId:X4} | {Escape(stream.SvId)} | {(stream.IsBoundToScl ? "yes" : "no")} | {stream.NofAsdu} | {stream.ActualFps:0.0} | {stream.LastSmpCnt?.ToString() ?? "-"} | {Escape(stream.QualitySummary)} | {issues} |");
- }
+ 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);
- lines.Add(string.Empty);
- lines.Add("## Phasors");
- lines.Add(string.Empty);
- foreach (var stream in snapshots)
- {
- lines.Add($"### 0x{stream.AppId:X4} — {stream.SvId}");
- lines.Add(string.Empty);
- lines.Add($"- Cursor: {stream.CursorSummary}");
- lines.Add("- Phasors:");
- if (stream.Phasors.Count == 0)
- {
- lines.Add(" - Not enough decoded waveform samples or no SCL binding.");
- }
- else
- {
- foreach (var phasor in stream.Phasors)
- lines.Add($" - {phasor.Channel}: RMS {phasor.Rms:0.###}, peak {phasor.Peak:0.###}, angle {phasor.AngleDegrees:0.0}°");
- }
- lines.Add(string.Empty);
+ StatusText = $"Evidence bundle exported: {Path.GetFileName(markdownPath)} + {Path.GetFileName(jsonPath)}";
}
-
- lines.Add("## Diagnostics");
- lines.Add(string.Empty);
- foreach (var stream in snapshots)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
{
- lines.Add($"### 0x{stream.AppId:X4} — {stream.SvId}");
- lines.Add(string.Empty);
- if (stream.Diagnostics.Count == 0)
- {
- lines.Add("- No diagnostics.");
- }
- else
- {
- foreach (var diagnostic in stream.Diagnostics)
- lines.Add($"- {diagnostic}");
- }
- lines.Add(string.Empty);
+ StatusText = $"Evidence export failed: {ex.Message}";
}
-
- await File.WriteAllLinesAsync(dialog.FileName, lines).ConfigureAwait(true);
- StatusText = $"Subscriber report exported: {dialog.FileName}";
}
-
- private static string Escape(string value)
- => string.IsNullOrWhiteSpace(value) ? "-" : value.Replace("|", "\\|", StringComparison.Ordinal);
}
diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs
new file mode 100644
index 0000000..6ec00fd
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs
@@ -0,0 +1,182 @@
+using AR.Iec61850.Ethernet;
+using AR.Iec61850.SampledValues;
+using AR.Iec61850.SampledValues.Profiles;
+using AR.Iec61850.Scl;
+using Xunit;
+
+namespace ARSVIN.Tests.SampledValues.Profiles;
+
+public sealed class SvExpectedConfigurationIntegrationTests
+{
+ [Fact]
+ public void FactoryConvertsSclProfileIntoExpectedConfiguration()
+ {
+ var expected = SvExpectedStreamConfigurationFactory.Create(CreateProfile());
+
+ Assert.Equal((ushort)0x88BA, expected.EtherType);
+ Assert.Equal((ushort)0x4001, expected.AppId);
+ Assert.Equal("01:0C:CD:04:00:01", expected.DestinationMac);
+ Assert.Equal((ushort)100, expected.VlanId);
+ Assert.Equal((byte)4, expected.VlanPriority);
+ Assert.Equal("MU01SV01", expected.SvId);
+ Assert.Equal("MU01MUnn/LLN0$PhsMeas", expected.DataSetReference);
+ Assert.Equal((uint)7, expected.ConfigurationRevision);
+ Assert.Equal(1, expected.AsduPerFrame);
+ Assert.Equal(8, expected.PayloadBytesPerAsdu);
+ Assert.Equal((ushort)80, expected.DeclaredSampleRate);
+ Assert.Equal((ushort)0, expected.DeclaredSampleMode);
+ Assert.Equal(2, expected.DataSetSignature.Count);
+ }
+
+ [Fact]
+ public void CompatibleComparisonIsDefaultAndReportsWarnings()
+ {
+ var manager = new SvStreamObservationManager();
+
+ Assert.True(manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ CreateFrame(configurationRevision: 8),
+ SvObservationInputKind.LiveCapture,
+ CreateProfile(),
+ out var snapshot));
+
+ var comparison = Assert.IsType(snapshot.ConfigurationComparison);
+ Assert.Equal(SvComparisonMode.Compatible, comparison.Mode);
+ Assert.False(comparison.HasBlockingErrors);
+ Assert.Equal(1, comparison.WarningCount);
+ Assert.Equal("1 warning", snapshot.ConfigurationMatchSummary);
+ Assert.Equal("SV_CONFREV_MISMATCH", Assert.Single(comparison.Findings).Code);
+ }
+
+ [Fact]
+ public void StrictComparisonReportsBlockingErrors()
+ {
+ var manager = new SvStreamObservationManager();
+
+ Assert.True(manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ CreateFrame(configurationRevision: 8),
+ SvObservationInputKind.PcapReplay,
+ CreateProfile(),
+ out var snapshot,
+ comparisonMode: SvComparisonMode.Strict));
+
+ var comparison = Assert.IsType(snapshot.ConfigurationComparison);
+ Assert.Equal(SvComparisonMode.Strict, comparison.Mode);
+ Assert.True(comparison.HasBlockingErrors);
+ Assert.Equal(1, comparison.ErrorCount);
+ Assert.Equal("1 error", snapshot.ConfigurationMatchSummary);
+ }
+
+ [Fact]
+ public void ExactComparisonPersistsInManagerSnapshots()
+ {
+ var manager = new SvStreamObservationManager();
+
+ Assert.True(manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ CreateFrame(configurationRevision: 7),
+ SvObservationInputKind.LiveCapture,
+ CreateProfile(),
+ out var observed));
+
+ Assert.Equal("Exact", observed.ConfigurationMatchSummary);
+ var persisted = Assert.Single(manager.SnapshotAll());
+ Assert.Equal("Exact", persisted.ConfigurationMatchSummary);
+ var comparison = Assert.IsType(persisted.ConfigurationComparison);
+ Assert.True(comparison.IsExactMatch);
+ }
+
+ [Fact]
+ public void AddressMismatchRejectsUnsafeSclCandidate()
+ {
+ var manager = new SvStreamObservationManager();
+ var frame = CreateFrame(
+ configurationRevision: 7,
+ destinationMac: "01:0C:CD:04:00:02");
+
+ Assert.True(manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ frame,
+ SvObservationInputKind.LiveCapture,
+ CreateProfile(),
+ out var snapshot));
+
+ Assert.False(snapshot.IsBoundToScl);
+ Assert.Null(snapshot.ExpectedConfiguration);
+ Assert.Null(snapshot.ConfigurationComparison);
+ Assert.Equal("Not configured", snapshot.ConfigurationMatchSummary);
+ Assert.Contains(snapshot.Diagnostics, item => item.Contains("Rejected SCL candidate", StringComparison.Ordinal));
+ }
+
+ private static SampledValuesFrame CreateFrame(
+ uint configurationRevision,
+ string destinationMac = "01:0C:CD:04:00:01")
+ => new()
+ {
+ Source = MacAddress.Parse("02:00:00:00:00:01"),
+ Destination = MacAddress.Parse(destinationMac),
+ Vlan = new VlanTag(4, 100),
+ AppId = 0x4001,
+ Pdu = new SampledValuesPdu
+ {
+ Asdus =
+ [
+ new SampledValueAsdu
+ {
+ SvId = "MU01SV01",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ SampleCount = 1,
+ ConfigurationRevision = configurationRevision,
+ SampleRate = 80,
+ SampleMode = 0,
+ SamplePayload = new byte[8]
+ }
+ ]
+ }
+ };
+
+ private static SampledValuesPublisherProfile CreateProfile()
+ => SampledValuesPublisherProfile.Create(new SclSampledValuesStream
+ {
+ Kind = "SV",
+ IedName = "MU01",
+ LdInst = "MUnn",
+ ControlName = "MSVCB01",
+ ControlBlockReference = "MU01MUnn/LLN0$SV$MSVCB01",
+ SvId = "MU01SV01",
+ DataSetName = "PhsMeas",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ ConfigurationRevision = 7,
+ SampleRate = 80,
+ SampleMode = "SmpPerPeriod",
+ NoAsdu = 1,
+ Address = new SclStreamAddress
+ {
+ AppIdText = "0x4001",
+ AppId = 0x4001,
+ DestinationMacText = "01:0C:CD:04:00:01",
+ DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01"),
+ VlanId = 100,
+ VlanPriority = 4
+ },
+ Entries =
+ [
+ new SclDataSetEntry
+ {
+ Index = 1,
+ SignalReference = "TCTR1.Amp.instMag.i",
+ BType = "INT32",
+ Cdc = "SAV"
+ },
+ new SclDataSetEntry
+ {
+ Index = 2,
+ SignalReference = "TCTR1.Amp.q",
+ BType = "Quality",
+ Cdc = "SAV",
+ IsQuality = true
+ }
+ ]
+ });
+}
diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationWindowDefaultsTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationWindowDefaultsTests.cs
new file mode 100644
index 0000000..8fcae5e
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationWindowDefaultsTests.cs
@@ -0,0 +1,14 @@
+using AR.Iec61850.SampledValues.Profiles;
+using Xunit;
+
+namespace ARSVIN.Tests.SampledValues.Profiles;
+
+public sealed class SvObservationWindowDefaultsTests
+{
+ [Fact]
+ public void DefaultWindowSupportsTwoSecondsOfHighRateSampledValues()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(2), SvStreamObservationManager.DefaultMaximumAge);
+ Assert.True(SvStreamObservationManager.DefaultMaximumObservations >= 9_600);
+ }
+}
diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceComparisonTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceComparisonTests.cs
new file mode 100644
index 0000000..a72d222
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceComparisonTests.cs
@@ -0,0 +1,200 @@
+using AR.Iec61850.SampledValues.Profiles;
+using AR.Iec61850.SampledValues.Reporting;
+using Xunit;
+
+namespace ARSVIN.Tests.SampledValues.Profiles;
+
+public sealed class SvSubscriberEvidenceComparisonTests
+{
+ [Fact]
+ public void SourceFailoverMatchesLogicalStreamAndSurfacesRegression()
+ {
+ var baseline = CreateReport(CreateStream("stream-a", "02:00:00:00:00:01", "GOOD", sequenceGaps: 0));
+ var candidate = CreateReport(
+ CreateStream("stream-b", "02:00:00:00:00:02", "BAD", sequenceGaps: 2),
+ commit: "candidate123");
+
+ var comparison = new SvSubscriberEvidenceComparator().Compare(
+ baseline,
+ candidate,
+ DateTimeOffset.UnixEpoch.AddMinutes(5));
+
+ var stream = Assert.Single(comparison.Streams);
+ Assert.Equal(SvEvidenceChangeKind.Changed, stream.Kind);
+ Assert.Equal(SvEvidenceChangeSeverity.Error, stream.Severity);
+ Assert.Contains(stream.Changes, change => change.Field == "Source MAC");
+ Assert.Contains(stream.Changes, change =>
+ change.Field == "Health" && change.Severity == SvEvidenceChangeSeverity.Error);
+ Assert.Contains(stream.Changes, change =>
+ change.Field == "Sequence gaps" && change.Severity == SvEvidenceChangeSeverity.Warning);
+ Assert.Equal(0, comparison.Summary.AddedStreamCount);
+ Assert.Equal(0, comparison.Summary.RemovedStreamCount);
+ Assert.True(comparison.Summary.HasRegressions);
+ }
+
+ [Fact]
+ public void MissingAndNewLogicalStreamsAreClassifiedDeterministically()
+ {
+ var baseline = CreateReport(CreateStream("baseline", "02:00:00:00:00:01", "GOOD", svId: "MU01SV01"));
+ var candidate = CreateReport(CreateStream("candidate", "02:00:00:00:00:02", "GOOD", svId: "MU02SV01"));
+
+ var comparison = new SvSubscriberEvidenceComparator().Compare(
+ baseline,
+ candidate,
+ DateTimeOffset.UnixEpoch.AddMinutes(5));
+
+ Assert.Equal(2, comparison.Streams.Count);
+ Assert.Equal(1, comparison.Summary.AddedStreamCount);
+ Assert.Equal(1, comparison.Summary.RemovedStreamCount);
+ Assert.Contains(comparison.Streams, stream =>
+ stream.Kind == SvEvidenceChangeKind.Removed && stream.Severity == SvEvidenceChangeSeverity.Error);
+ Assert.Contains(comparison.Streams, stream =>
+ stream.Kind == SvEvidenceChangeKind.Added && stream.Severity == SvEvidenceChangeSeverity.Info);
+ }
+
+ [Fact]
+ public void JsonRoundTripAndMarkdownPreserveRegressionEvidence()
+ {
+ var baseline = CreateReport(CreateStream("stream-a", "02:00:00:00:00:01", "GOOD", sequenceGaps: 0));
+ var candidate = CreateReport(CreateStream("stream-a", "02:00:00:00:00:01", "WARN", sequenceGaps: 1));
+ var comparison = new SvSubscriberEvidenceComparator().Compare(
+ baseline,
+ candidate,
+ DateTimeOffset.UnixEpoch.AddMinutes(5));
+
+ var json = SvSubscriberEvidenceComparisonSerializer.ToJson(comparison);
+ var restored = SvSubscriberEvidenceComparisonSerializer.FromJson(json);
+ var markdown = SvSubscriberEvidenceComparisonSerializer.ToMarkdown(restored);
+
+ Assert.Contains("arsvin.sv-subscriber-evidence-comparison/v1", json, StringComparison.Ordinal);
+ Assert.Contains("warning", json, StringComparison.Ordinal);
+ Assert.Equal(comparison.Summary.WarningChangeCount, restored.Summary.WarningChangeCount);
+ Assert.Contains("# ARSVIN Subscriber Evidence Comparison", markdown, StringComparison.Ordinal);
+ Assert.Contains("REVIEW REQUIRED", markdown, StringComparison.Ordinal);
+ Assert.Contains("Sequence gaps", markdown, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RateMovementInsideToleranceDoesNotCreateFalseRegression()
+ {
+ var baselineStream = CreateStream("stream-a", "02:00:00:00:00:01", "GOOD", observedSamplesPerSecond: 4000);
+ var candidateStream = CreateStream("stream-a", "02:00:00:00:00:01", "GOOD", observedSamplesPerSecond: 4020);
+ var baseline = CreateReport(baselineStream);
+ var candidate = CreateReport(candidateStream);
+
+ var comparison = new SvSubscriberEvidenceComparator().Compare(
+ baseline,
+ candidate,
+ DateTimeOffset.UnixEpoch.AddMinutes(5));
+
+ var stream = Assert.Single(comparison.Streams);
+ Assert.Equal(SvEvidenceChangeKind.Unchanged, stream.Kind);
+ Assert.Empty(stream.Changes);
+ Assert.False(comparison.Summary.HasRegressions);
+ }
+
+ private static SvSubscriberEvidenceReport CreateReport(
+ SvSubscriberStreamEvidence stream,
+ string commit = "baseline123")
+ => new()
+ {
+ GeneratedAt = DateTimeOffset.UnixEpoch.AddMinutes(1),
+ Software = new SvSubscriberSoftwareEvidence
+ {
+ Product = "ARSVIN Subscriber",
+ Version = "0.4.0",
+ InformationalVersion = $"0.4.0+{commit}",
+ Commit = commit,
+ Repository = "https://github.com/masarray/arsvin"
+ },
+ Capture = new SvSubscriberCaptureEvidence
+ {
+ Source = "LiveCapture",
+ EndedAt = DateTimeOffset.UnixEpoch.AddMinutes(1),
+ RawFrames = 8000,
+ SvFrames = 8000
+ },
+ Summary = new SvSubscriberSummaryEvidence
+ {
+ Health = stream.Health,
+ StreamCount = 1
+ },
+ Streams = [stream]
+ };
+
+ private static SvSubscriberStreamEvidence CreateStream(
+ string key,
+ string sourceMac,
+ string health,
+ int sequenceGaps = 0,
+ string svId = "MU01SV01",
+ double observedSamplesPerSecond = 4000)
+ {
+ var facts = new SvObservedStreamFacts
+ {
+ EtherType = 0x88BA,
+ AppId = 0x4001,
+ DestinationMac = "01:0C:CD:04:00:01",
+ VlanId = 100,
+ VlanPriority = 4,
+ SvId = svId,
+ DataSetReference = $"{svId}/LLN0$PhsMeas",
+ ConfigurationRevision = 7,
+ AsduPerFrame = 1,
+ PayloadBytesPerAsdu = 8,
+ ObservedFramesPerSecond = observedSamplesPerSecond,
+ ObservedSamplesPerSecond = observedSamplesPerSecond,
+ ObservedCounterWrap = 4000,
+ DeclaredSampleRate = 80,
+ DeclaredSampleMode = 0,
+ Provenance = new Dictionary(StringComparer.Ordinal)
+ {
+ [nameof(SvObservedStreamFacts.AppId)] = SvFactSource.WireObserved,
+ [nameof(SvObservedStreamFacts.ObservedSamplesPerSecond)] = SvFactSource.CaptureCalculated
+ },
+ ObservationCount = 8000,
+ FirstTimestamp = DateTimeOffset.UnixEpoch,
+ LastTimestamp = DateTimeOffset.UnixEpoch.AddSeconds(2)
+ };
+
+ return new SvSubscriberStreamEvidence
+ {
+ Key = key,
+ Health = health,
+ Identity = new SvSubscriberStreamIdentityEvidence
+ {
+ AppId = 0x4001,
+ SourceMac = sourceMac,
+ DestinationMac = facts.DestinationMac,
+ VlanId = facts.VlanId,
+ VlanPriority = facts.VlanPriority,
+ SvId = facts.SvId,
+ DataSetReference = facts.DataSetReference,
+ ConfigurationRevision = facts.ConfigurationRevision,
+ AsduPerFrame = 1,
+ DeclaredSampleRate = facts.DeclaredSampleRate,
+ DeclaredSampleMode = facts.DeclaredSampleMode
+ },
+ Runtime = new SvSubscriberRuntimeEvidence
+ {
+ FrameCount = 8000,
+ AsduCount = 8000,
+ ActualFramesPerSecond = observedSamplesPerSecond,
+ SequenceGapCount = sequenceGaps,
+ IsWaveformWindowReady = true
+ },
+ Observation = new SvSubscriberObservationEvidence
+ {
+ InputKinds = [SvObservationInputKind.LiveCapture],
+ LastInputKind = SvObservationInputKind.LiveCapture,
+ WindowFrames = 8000,
+ WindowSamples = 8000,
+ WindowDurationSeconds = 2,
+ FirstTimestamp = facts.FirstTimestamp,
+ LastTimestamp = facts.LastTimestamp,
+ Facts = facts,
+ FactProvenance = facts.Provenance
+ }
+ };
+ }
+}
diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceReportTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceReportTests.cs
new file mode 100644
index 0000000..b9e426c
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceReportTests.cs
@@ -0,0 +1,267 @@
+using AR.Iec61850.SampledValues.Profiles;
+using AR.Iec61850.SampledValues.Reporting;
+using Xunit;
+
+namespace ARSVIN.Tests.SampledValues.Profiles;
+
+public sealed class SvSubscriberEvidenceReportTests
+{
+ [Fact]
+ public void JsonRoundTripPreservesEvidenceContract()
+ {
+ var report = CreateReport();
+
+ var json = SvSubscriberEvidenceReportSerializer.ToJson(report);
+ var restored = SvSubscriberEvidenceReportSerializer.FromJson(json);
+
+ Assert.Contains("arsvin.sv-subscriber-evidence/v1", json, StringComparison.Ordinal);
+ Assert.Contains("liveCapture", json, StringComparison.Ordinal);
+ Assert.Contains("wireObserved", json, StringComparison.Ordinal);
+ Assert.Contains("SV_CONFREV_MISMATCH", json, StringComparison.Ordinal);
+ Assert.Equal("abc1234", restored.Software.Commit);
+ var stream = Assert.Single(restored.Streams);
+ Assert.Equal(SvObservationInputKind.LiveCapture, Assert.Single(stream.Observation.InputKinds));
+ Assert.Equal(SvFactSource.WireObserved, stream.Observation.FactProvenance[nameof(SvObservedStreamFacts.AppId)]);
+ Assert.Equal("1 warning", stream.Observation.ConfigurationComparison?.Summary);
+ Assert.Equal(SvProfileConfidence.Likely, stream.Observation.ProfileDetection?.Confidence);
+ }
+
+ [Fact]
+ public void MarkdownIncludesProfileConfigurationProvenanceAndBuildIdentity()
+ {
+ var markdown = SvSubscriberEvidenceReportSerializer.ToMarkdown(CreateReport());
+
+ Assert.Contains("## Report metadata", markdown, StringComparison.Ordinal);
+ Assert.Contains("abc1234", markdown, StringComparison.Ordinal);
+ Assert.Contains("### Observed facts and provenance", markdown, StringComparison.Ordinal);
+ Assert.Contains("WireObserved", markdown, StringComparison.Ordinal);
+ Assert.Contains("### Expected SCL configuration", markdown, StringComparison.Ordinal);
+ Assert.Contains("### Configuration comparison", markdown, StringComparison.Ordinal);
+ Assert.Contains("SV_CONFREV_MISMATCH", markdown, StringComparison.Ordinal);
+ Assert.Contains("### Profile detection evidence", markdown, StringComparison.Ordinal);
+ Assert.Contains("Generic SCL-driven Layer-2 SV", markdown, StringComparison.Ordinal);
+ Assert.Contains("### Phasors", markdown, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ValidationRejectsDuplicateStreamKeys()
+ {
+ var report = CreateReport();
+ var duplicate = report with
+ {
+ Summary = report.Summary with { StreamCount = 2 },
+ Streams = [report.Streams[0], report.Streams[0]]
+ };
+
+ var exception = Assert.Throws(() =>
+ SvSubscriberEvidenceReportSerializer.ToJson(duplicate));
+
+ Assert.Contains("unique", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static SvSubscriberEvidenceReport CreateReport()
+ {
+ var facts = new SvObservedStreamFacts
+ {
+ EtherType = 0x88BA,
+ AppId = 0x4001,
+ DestinationMac = "01:0C:CD:04:00:01",
+ VlanId = 100,
+ VlanPriority = 4,
+ SvId = "MU01SV01",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ ConfigurationRevision = 8,
+ AsduPerFrame = 1,
+ PayloadBytesPerAsdu = 8,
+ ObservedFramesPerSecond = 4000,
+ ObservedSamplesPerSecond = 4000,
+ ObservedCounterWrap = 4000,
+ CounterTransitions = new SvCounterTransitionSummary
+ {
+ SequentialCount = 7998,
+ GapCount = 1,
+ DuplicateCount = 0,
+ OutOfOrderOrResetCount = 0,
+ ConfirmedWrapCount = 1
+ },
+ DeclaredSampleRate = 80,
+ DeclaredSampleMode = 0,
+ DataSetSignature =
+ [
+ new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" },
+ new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true }
+ ],
+ Provenance = new Dictionary(StringComparer.Ordinal)
+ {
+ [nameof(SvObservedStreamFacts.AppId)] = SvFactSource.WireObserved,
+ [nameof(SvObservedStreamFacts.ObservedFramesPerSecond)] = SvFactSource.CaptureCalculated,
+ [nameof(SvObservedStreamFacts.DataSetSignature)] = SvFactSource.SclDerived
+ },
+ ObservationCount = 8000,
+ FirstTimestamp = DateTimeOffset.UnixEpoch,
+ LastTimestamp = DateTimeOffset.UnixEpoch.AddSeconds(2)
+ };
+ var expected = new SvExpectedStreamConfiguration
+ {
+ EtherType = 0x88BA,
+ AppId = 0x4001,
+ DestinationMac = "01:0C:CD:04:00:01",
+ VlanId = 100,
+ VlanPriority = 4,
+ SvId = "MU01SV01",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ ConfigurationRevision = 7,
+ AsduPerFrame = 1,
+ PayloadBytesPerAsdu = 8,
+ DeclaredSampleRate = 80,
+ DeclaredSampleMode = 0,
+ DataSetSignature = facts.DataSetSignature
+ };
+ var comparison = new SvConfigurationComparisonResult
+ {
+ Mode = SvComparisonMode.Compatible,
+ Findings =
+ [
+ new SvConfigurationFinding(
+ SvConfigurationFindingSeverity.Warning,
+ "SV_CONFREV_MISMATCH",
+ "confRev",
+ "7",
+ "8",
+ "Configured confRev differs from observed traffic. Capture and decoding remain active.")
+ ]
+ };
+ var profile = new SvProfileDetectionResult
+ {
+ Profile = new SvProfileDefinition
+ {
+ Id = "generic-scl-layer2",
+ DisplayName = "Generic SCL-driven Layer-2 SV",
+ Family = "Generic Layer-2 SV",
+ SamplingBasis = SvSamplingBasis.Custom,
+ ExpectedEtherType = 0x88BA,
+ EvidenceStatus = SvProfileEvidenceStatus.ImplementedGeneric,
+ Sources =
+ [
+ new SvProfileSourceEvidence(
+ "arsvin-engine",
+ "Generic Layer-2 SV mechanisms implemented by the shared engine.",
+ SvProfileEvidenceStatus.ImplementedGeneric)
+ ]
+ },
+ RawConfidence = SvProfileConfidence.Likely,
+ ScorePercent = 100,
+ MatchedWeight = 5,
+ EvaluatedWeight = 5,
+ Evidence =
+ [
+ new SvProfileMatchEvidence(
+ "EtherType",
+ SvProfileEvidenceOutcome.Match,
+ 5,
+ "0x88BA",
+ "0x88BA",
+ "EtherType matches the generic Layer-2 SV transport.")
+ ]
+ };
+
+ return new SvSubscriberEvidenceReport
+ {
+ GeneratedAt = DateTimeOffset.UnixEpoch.AddHours(1),
+ Software = new SvSubscriberSoftwareEvidence
+ {
+ Product = "ArSubsv",
+ Version = "0.4.0.0",
+ InformationalVersion = "0.4.0+abc1234",
+ Commit = "abc1234",
+ Repository = "https://github.com/masarray/arsvin"
+ },
+ Capture = new SvSubscriberCaptureEvidence
+ {
+ Source = "LiveCapture",
+ SclPath = "station.scd",
+ Adapter = "Ethernet 1",
+ Filter = "0x4001",
+ StartedAt = DateTimeOffset.UnixEpoch,
+ EndedAt = DateTimeOffset.UnixEpoch.AddSeconds(2),
+ DurationSeconds = 2,
+ RawFrames = 8000,
+ SvFrames = 8000
+ },
+ Summary = new SvSubscriberSummaryEvidence
+ {
+ Health = "WARN",
+ StreamCount = 1,
+ RuntimeIssueCount = 1,
+ ConfigurationFindingCount = 1
+ },
+ Streams =
+ [
+ new SvSubscriberStreamEvidence
+ {
+ Key = "SV|4001|02:00:00:00:00:01|01:0C:CD:04:00:01|100|MU01SV01|MU01MUnn/LLN0$PhsMeas",
+ Health = "WARN",
+ HealthDetail = "Configuration differs from observed traffic.",
+ Identity = new SvSubscriberStreamIdentityEvidence
+ {
+ AppId = 0x4001,
+ SourceMac = "02:00:00:00:00:01",
+ DestinationMac = "01:0C:CD:04:00:01",
+ VlanId = 100,
+ VlanPriority = 4,
+ SvId = "MU01SV01",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ ConfigurationRevision = 8,
+ AsduPerFrame = 1,
+ LastSampleCount = 3999,
+ DeclaredSampleRate = 80,
+ DeclaredSampleMode = 0,
+ SampleSynchronization = 2
+ },
+ Runtime = new SvSubscriberRuntimeEvidence
+ {
+ FrameCount = 8000,
+ AsduCount = 8000,
+ ActualFramesPerSecond = 4000,
+ SequenceGapCount = 1,
+ IsWaveformWindowReady = true,
+ LayoutBinding = "SCL: MU01MUnn/LLN0$SV$MSVCB01",
+ QualitySummary = "Quality good 8,000, non-zero 0",
+ CursorSummary = "Cursor compare ready",
+ LastSeen = "00:00:02.000"
+ },
+ Observation = new SvSubscriberObservationEvidence
+ {
+ InputKinds = [SvObservationInputKind.LiveCapture],
+ LastInputKind = SvObservationInputKind.LiveCapture,
+ IsBoundToScl = true,
+ ControlBlockReference = "MU01MUnn/LLN0$SV$MSVCB01",
+ WindowFrames = 8000,
+ WindowSamples = 8000,
+ WindowDurationSeconds = 2,
+ FirstTimestamp = facts.FirstTimestamp,
+ LastTimestamp = facts.LastTimestamp,
+ Facts = facts,
+ FactProvenance = facts.Provenance,
+ ProfileDetection = profile,
+ ExpectedConfiguration = expected,
+ ConfigurationComparison = comparison,
+ Diagnostics = ["Observed 1 forward sample-counter gap transition(s)."]
+ },
+ Phasors =
+ [
+ new SvSubscriberPhasorEvidence
+ {
+ Channel = "Ia",
+ Kind = "Current",
+ Rms = 1000,
+ Peak = 1414.2,
+ AngleDegrees = 0
+ }
+ ],
+ Diagnostics = ["Configured confRev differs from observed traffic."]
+ }
+ ]
+ };
+ }
+}
diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberUxContractTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberUxContractTests.cs
new file mode 100644
index 0000000..dd3a0ad
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberUxContractTests.cs
@@ -0,0 +1,77 @@
+using AR.Iec61850.Ethernet;
+using AR.Iec61850.SampledValues;
+using AR.Iec61850.SampledValues.Profiles;
+using Xunit;
+
+namespace ARSVIN.Tests.SampledValues.Profiles;
+
+public sealed class SvSubscriberUxContractTests
+{
+ [Fact]
+ public void GenericLayer2ProfileRemainsUnknownWithInsufficientEvidence()
+ {
+ var manager = new SvStreamObservationManager();
+
+ Assert.True(manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ CreateFrame(sampleCount: 1),
+ SvObservationInputKind.LiveCapture,
+ profile: null,
+ out var snapshot));
+
+ var detection = Assert.IsType(snapshot.ProfileDetection);
+ Assert.Equal("Generic Layer-2 SV", detection.Profile.Family);
+ Assert.Equal(SvProfileConfidence.Unknown, detection.Confidence);
+ Assert.Equal(5, detection.EvaluatedWeight);
+ Assert.Equal(100, detection.ScorePercent);
+ }
+
+ [Fact]
+ public void ObservationSnapshotCarriesWindowFactsForCompactState()
+ {
+ var manager = new SvStreamObservationManager();
+
+ manager.TryObserve(
+ DateTimeOffset.UnixEpoch,
+ CreateFrame(sampleCount: 10),
+ SvObservationInputKind.PcapReplay,
+ profile: null,
+ out _);
+ manager.TryObserve(
+ DateTimeOffset.UnixEpoch.AddSeconds(2),
+ CreateFrame(sampleCount: 11),
+ SvObservationInputKind.PcapReplay,
+ profile: null,
+ out var snapshot);
+
+ Assert.Equal(2, snapshot.Facts.ObservationCount);
+ Assert.Equal(1, snapshot.Facts.AsduPerFrame);
+ Assert.Equal(DateTimeOffset.UnixEpoch, snapshot.Facts.FirstTimestamp);
+ Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(2), snapshot.Facts.LastTimestamp);
+ }
+
+ private static SampledValuesFrame CreateFrame(ushort sampleCount)
+ => new()
+ {
+ Source = MacAddress.Parse("02:00:00:00:00:01"),
+ Destination = MacAddress.Parse("01:0C:CD:04:00:01"),
+ Vlan = new VlanTag(4, 100),
+ AppId = 0x4001,
+ Pdu = new SampledValuesPdu
+ {
+ Asdus =
+ [
+ new SampledValueAsdu
+ {
+ SvId = "MU01SV01",
+ DataSetReference = "MU01MUnn/LLN0$PhsMeas",
+ SampleCount = sampleCount,
+ ConfigurationRevision = 7,
+ SampleRate = 80,
+ SampleMode = 0,
+ SamplePayload = new byte[8]
+ }
+ ]
+ }
+ };
+}