From e0d2d3e8b1ad8cc8666b615dae8fc22d36d655eb Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:32:41 +0700 Subject: [PATCH 01/39] Add SCL expected configuration factory --- .../SvExpectedStreamConfigurationFactory.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs 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 + }; +} From afb364755f01209e33b074f7425d66c0764cbe92 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:33:28 +0700 Subject: [PATCH 02/39] Add configuration comparison summaries --- .../Profiles/SvConfigurationComparer.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 From 8392588d301d31346dc76f537ca8992a263e2ccb Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:34:15 +0700 Subject: [PATCH 03/39] Integrate SCL configuration comparison into SV observations --- .../Profiles/SvStreamObservationManager.cs | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 96b8b3b..39ebbc3 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -55,6 +55,9 @@ 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 string ConfigurationMatchSummary => ConfigurationComparison?.Summary ?? "Not configured"; public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); } @@ -150,7 +153,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 +165,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,8 +194,25 @@ public bool TryObserve( var state = _streams.GetOrAdd( key, _ => new StreamState(_maximumObservations, _maximumAge)); - state.Add(observation, inputKind, profile, diagnostics); - snapshot = state.Snapshot(key); + state.Add(observation, inputKind, boundProfile, diagnostics); + + var observedSnapshot = state.Snapshot(key); + if (boundProfile is null) + { + snapshot = observedSnapshot; + return true; + } + + var expected = SvExpectedStreamConfigurationFactory.Create(boundProfile); + var comparison = new SvConfigurationComparer().Compare( + expected, + observedSnapshot.Facts, + comparisonMode); + snapshot = observedSnapshot with + { + ExpectedConfiguration = expected, + ConfigurationComparison = comparison + }; return true; } @@ -216,6 +238,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(); From 09c03c9f00fd2e12dc4a5056b34a0a3bb5e95828 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:35:20 +0700 Subject: [PATCH 04/39] Persist SV configuration comparison in observation snapshots --- .../Profiles/SvStreamObservationManager.cs | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 39ebbc3..826a9b3 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -68,6 +68,8 @@ 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) { @@ -83,6 +85,7 @@ public void Add( SvFrameObservation observation, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, + SvComparisonMode comparisonMode, IEnumerable diagnostics) { Accumulator.Add(observation); @@ -94,6 +97,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))) @@ -113,6 +118,13 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) var facts = Accumulator.BuildFacts(); lock (_gate) { + var comparison = _expectedConfiguration is null + ? null + : new SvConfigurationComparer().Compare( + _expectedConfiguration, + facts, + _comparisonMode); + return new SvStreamObservationSnapshot { Key = key, @@ -121,6 +133,8 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) LastInputKind = LastInputKind, IsBoundToScl = IsBoundToScl, ControlBlockReference = ControlBlockReference, + ExpectedConfiguration = _expectedConfiguration, + ConfigurationComparison = comparison, Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() }; } @@ -194,25 +208,8 @@ public bool TryObserve( var state = _streams.GetOrAdd( key, _ => new StreamState(_maximumObservations, _maximumAge)); - state.Add(observation, inputKind, boundProfile, diagnostics); - - var observedSnapshot = state.Snapshot(key); - if (boundProfile is null) - { - snapshot = observedSnapshot; - return true; - } - - var expected = SvExpectedStreamConfigurationFactory.Create(boundProfile); - var comparison = new SvConfigurationComparer().Compare( - expected, - observedSnapshot.Facts, - comparisonMode); - snapshot = observedSnapshot with - { - ExpectedConfiguration = expected, - ConfigurationComparison = comparison - }; + state.Add(observation, inputKind, boundProfile, comparisonMode, diagnostics); + snapshot = state.Snapshot(key); return true; } @@ -270,7 +267,8 @@ private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList !string.Equals(item.DataSetReference, first.DataSetReference, StringComparison.Ordinal))) diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references."); - if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision)) + if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision) + ) diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values."); if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1) diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths."); From 3537431ac6f07267cb46d62af829d5a9670598e6 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:35:53 +0700 Subject: [PATCH 05/39] Add deterministic P3C configuration comparison tests --- ...SvExpectedConfigurationIntegrationTests.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs new file mode 100644 index 0000000..b577e52 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs @@ -0,0 +1,180 @@ +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); + Assert.True(persisted.ConfigurationComparison?.IsExactMatch); + } + + [Fact] + public void AddressMismatchRejectsUnsafeSclCandidate() + { + var manager = new SvStreamObservationManager(); + var frame = CreateFrame(configurationRevision: 7) with + { + Destination = MacAddress.Parse("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) + => 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 = 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 + } + ] + }); +} From 9a90c91e2f946a0650ab0dcd2d6e443e3da1d24b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:37:38 +0700 Subject: [PATCH 06/39] Fix P3C integration test frame construction --- .../SvExpectedConfigurationIntegrationTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs index b577e52..6ec00fd 100644 --- a/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvExpectedConfigurationIntegrationTests.cs @@ -83,17 +83,17 @@ public void ExactComparisonPersistsInManagerSnapshots() Assert.Equal("Exact", observed.ConfigurationMatchSummary); var persisted = Assert.Single(manager.SnapshotAll()); Assert.Equal("Exact", persisted.ConfigurationMatchSummary); - Assert.True(persisted.ConfigurationComparison?.IsExactMatch); + var comparison = Assert.IsType(persisted.ConfigurationComparison); + Assert.True(comparison.IsExactMatch); } [Fact] public void AddressMismatchRejectsUnsafeSclCandidate() { var manager = new SvStreamObservationManager(); - var frame = CreateFrame(configurationRevision: 7) with - { - Destination = MacAddress.Parse("01:0C:CD:04:00:02") - }; + var frame = CreateFrame( + configurationRevision: 7, + destinationMac: "01:0C:CD:04:00:02"); Assert.True(manager.TryObserve( DateTimeOffset.UnixEpoch, @@ -109,11 +109,13 @@ public void AddressMismatchRejectsUnsafeSclCandidate() Assert.Contains(snapshot.Diagnostics, item => item.Contains("Rejected SCL candidate", StringComparison.Ordinal)); } - private static SampledValuesFrame CreateFrame(uint configurationRevision) + 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("01:0C:CD:04:00:01"), + Destination = MacAddress.Parse(destinationMac), Vlan = new VlanTag(4, 100), AppId = 0x4001, Pdu = new SampledValuesPdu From f85c9a688a987b45e7ae61afa8bfc1e47af0788a Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:38:13 +0700 Subject: [PATCH 07/39] Document completed SCL configuration integration --- docs/sv-profile-infrastructure.md | 32 +++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index d0d8ff5..2ec52c8 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,15 +75,25 @@ 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. +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. + ## Current integration boundary `SvStreamObservationManager` now: @@ -92,13 +102,15 @@ Neither mode stops receive-side capture or decoding. Unknown and conflicting str - 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`); +- validates the SCL candidate against the observed address tuple; +- converts a bound SCL stream into `SvExpectedStreamConfiguration`; +- runs Compatible comparison by default, with Strict available per observation call; - adds SCL-derived dataset signatures when a stream is bound; and -- exposes immutable rolling facts to the Subscriber runtime snapshot. +- exposes immutable rolling facts plus persistent configuration comparison results to Subscriber runtime snapshots. ## 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. Present one compact profile/confidence and SCL-match state per selected stream. +2. Show evidence and mismatch details on demand rather than adding permanent visual noise. +3. Add profile-specific definitions only after source review and deterministic evidence. +4. Serialize profile, configuration, provenance, and source evidence into Subscriber reports. From 755990b5faa025a95db2081ad65b77c1f14f6333 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 21:40:44 +0700 Subject: [PATCH 08/39] Clean SV observation consistency check --- .../SampledValues/Profiles/SvStreamObservationManager.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 826a9b3..e61e82e 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -267,8 +267,7 @@ private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList !string.Equals(item.DataSetReference, first.DataSetReference, StringComparison.Ordinal))) diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references."); - if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision) - ) + if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision)) diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values."); if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1) diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths."); From 433d1eb277fa10e74d1f29bcab36b612c2d9d384 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:00:56 +0700 Subject: [PATCH 09/39] Add profile detection to SV observation snapshots --- .../SampledValues/Profiles/SvStreamObservationManager.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index e61e82e..2bc358e 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -57,6 +57,7 @@ public sealed record SvStreamObservationSnapshot 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(); } @@ -124,6 +125,9 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) _expectedConfiguration, facts, _comparisonMode); + var profileDetection = new SvProfileDetector().DetectBest( + facts, + SvProfileCatalog.BuiltIn); return new SvStreamObservationSnapshot { @@ -135,6 +139,7 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) ControlBlockReference = ControlBlockReference, ExpectedConfiguration = _expectedConfiguration, ConfigurationComparison = comparison, + ProfileDetection = profileDetection, Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() }; } From 83e954ab85ba5104ef671fb9ddc64c1b1d5f7086 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:01:04 +0700 Subject: [PATCH 10/39] Add single-reset collection updates for Subscriber visuals --- .../ViewModels/BulkObservableCollection.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/ARSVIN.Subscriber/ViewModels/BulkObservableCollection.cs 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)); + } +} From 959fd553f1f654c034eb66a6c5089fb12e8cb6a2 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:01:17 +0700 Subject: [PATCH 11/39] Expose compact Subscriber analysis state --- src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); } - From bc6a16892ecfce9000414e9c4ea3c49b4a18a5a0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:03:15 +0700 Subject: [PATCH 12/39] Add compact profile and evidence state to Subscriber streams --- .../ViewModels/SvStreamViewModel.cs | 171 ++++++++++++++++-- 1 file changed, 152 insertions(+), 19 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs index 09f9f9e..31b8fd6 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using AR.Iec61850.SampledValues.Profiles; using ARSVIN.Subscriber.Models; namespace ARSVIN.Subscriber.ViewModels; @@ -29,10 +29,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 BulkObservableCollection Values { get; } = new(); + public BulkObservableCollection WaveformPoints { get; } = new(); + public BulkObservableCollection Phasors { get; } = new(); + public BulkObservableCollection EvidenceDetails { get; } = new(); public string Key { get => _key; set => SetProperty(ref _key, value); } public string Health { get => _health; set => SetProperty(ref _health, value); } @@ -58,12 +65,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 +92,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(); } } From 65480aa1dc42fd12a62805a4c1cf4408ce6900eb Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:04:26 +0700 Subject: [PATCH 13/39] Wire compact P3D state into Subscriber refresh pipeline --- .../ViewModels/SvSubscriberViewModel.cs | 74 +++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs index 16bbcfc..18ed11f 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs @@ -15,6 +15,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 +61,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 +182,6 @@ private async Task OpenSclAsync() } } - private async Task OpenCaptureFileAsync() { var dialog = new OpenFileDialog @@ -247,8 +247,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 +345,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 addressCandidates.Length == 1 ? addressCandidates[0] : null; } private void RefreshUiSnapshots() @@ -360,7 +388,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 +407,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 +416,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 +434,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 +451,7 @@ private void Clear() { ClearRuntimeOnly(); Streams.Clear(); - SelectedValues.Clear(); + SelectedValues.ReplaceAll(Array.Empty()); _streamRows.Clear(); SelectedStream = null; HealthText = "IDLE"; @@ -431,10 +462,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); From 09d80c66dff252876b8d81b2623b89c21b76c54f Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:05:27 +0700 Subject: [PATCH 14/39] Add compact P3D status and expandable evidence panel --- src/ARSVIN.Subscriber/MainWindow.xaml | 54 ++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml index b965643..11a9f7f 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml +++ b/src/ARSVIN.Subscriber/MainWindow.xaml @@ -182,6 +182,8 @@ + + @@ -205,7 +207,55 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -213,7 +263,7 @@ - + From 9c8c5aa5f85803b965d53eb5bfcefccbf28bb0a6 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:05:43 +0700 Subject: [PATCH 15/39] Add deterministic P3D presentation contract tests --- .../Profiles/SvSubscriberUxContractTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberUxContractTests.cs 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] + } + ] + } + }; +} From ebaddf3e7813e725f7473754c17aa2ad213d2640 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:06:42 +0700 Subject: [PATCH 16/39] Expose bulk collections through stable read-only bindings --- .../ViewModels/SvStreamViewModel.cs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs index 31b8fd6..976565b 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs @@ -5,6 +5,10 @@ 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; @@ -36,10 +40,10 @@ public sealed class SvStreamViewModel : ObservableObject private string _comparisonMode = "Compatible"; private string _waveformState = "Waiting for complete two-cycle window"; - public BulkObservableCollection Values { get; } = new(); - public BulkObservableCollection WaveformPoints { get; } = new(); - public BulkObservableCollection Phasors { get; } = new(); - public BulkObservableCollection EvidenceDetails { 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); } @@ -130,9 +134,9 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ var samples = ResolveObservationSamples(observation?.Facts); Window = $"{duration:0.0} s · {samples:N0} samples"; - Values.ReplaceAll(snapshot.Values.Take(64)); + _values.ReplaceAll(snapshot.Values.Take(64)); UpdateStableVisuals(snapshot); - EvidenceDetails.ReplaceAll(BuildEvidenceDetails(observation)); + _evidenceDetails.ReplaceAll(BuildEvidenceDetails(observation)); } private void UpdateStableVisuals(SvStreamSnapshot snapshot) @@ -141,14 +145,14 @@ private void UpdateStableVisuals(SvStreamSnapshot snapshot) var fullWindow = snapshot.WaveformPoints.Count >= expectedPoints; if (fullWindow) { - WaveformPoints.ReplaceAll(snapshot.WaveformPoints.TakeLast(expectedPoints)); - Phasors.ReplaceAll(snapshot.Phasors); + _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()); + if (_waveformPoints.Count == 0) + _phasors.ReplaceAll(Array.Empty()); WaveformState = $"Filling full window · {snapshot.WaveformPoints.Count:N0}/{expectedPoints:N0} points"; } From 106606261a3b01d90da8d8e8570ed95ea562aa27 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:07:04 +0700 Subject: [PATCH 17/39] Document compact Subscriber evidence UX --- docs/sv-profile-infrastructure.md | 43 +++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index 2ec52c8..7eb0547 100644 --- a/docs/sv-profile-infrastructure.md +++ b/docs/sv-profile-infrastructure.md @@ -94,23 +94,38 @@ Neither mode stops receive-side capture or decoding. Unknown and conflicting str 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 now 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. + ## Current integration boundary -`SvStreamObservationManager` now: +`SvStreamObservationManager` and Subscriber now: -- accepts parsed frames from live Npcap capture and classic-PCAP replay; -- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference; -- deliberately keeps `confRev` outside the key so revision changes remain observable; -- retains input provenance (`LiveCapture` or `PcapReplay`); -- validates the SCL candidate against the observed address tuple; -- converts a bound SCL stream into `SvExpectedStreamConfiguration`; -- runs Compatible comparison by default, with Strict available per observation call; -- adds SCL-derived dataset signatures when a stream is bound; and -- exposes immutable rolling facts plus persistent configuration comparison results to Subscriber runtime snapshots. +- 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; and +- keep full-window waveform, phasor, and RMS visualization stable through bulk collection refreshes. ## Next integration -1. Present one compact profile/confidence and SCL-match state per selected stream. -2. Show evidence and mismatch details on demand rather than adding permanent visual noise. -3. Add profile-specific definitions only after source review and deterministic evidence. -4. Serialize profile, configuration, provenance, and source evidence into Subscriber reports. +1. Add profile-specific definitions only after source review and deterministic evidence. +2. Serialize profile, configuration, provenance, observation-window, and source evidence into Subscriber reports. From b1600a14867c5547f341a77c6afc693db1965ac7 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:09:46 +0700 Subject: [PATCH 18/39] Use a two-second Subscriber observation window --- .../SampledValues/Profiles/SvStreamObservationManager.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 2bc358e..9b43c2f 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -64,6 +64,9 @@ public sealed record SvStreamObservationSnapshot 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(); @@ -151,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)); From d9d3ffeee1edd0ce4ecb5dc18c39e2e98d78887f Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:09:55 +0700 Subject: [PATCH 19/39] Lock the two-second Subscriber observation defaults --- .../Profiles/SvObservationWindowDefaultsTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationWindowDefaultsTests.cs 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); + } +} From cb06dcb8899c7ba8d3931ae0a6ab32299dfa2bb7 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:36:10 +0700 Subject: [PATCH 20/39] Add deterministic SV evidence report schema and serializers --- .../Reporting/SvSubscriberEvidenceReport.cs | 587 ++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs 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); +} From 03455ce9f3aa5e2b23c5597c61431a7ddf22bc01 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:36:48 +0700 Subject: [PATCH 21/39] Build Subscriber evidence reports from runtime and observation snapshots --- .../Reporting/SvSubscriberReportBuilder.cs | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs diff --git a/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs new file mode 100644 index 0000000..c392fad --- /dev/null +++ b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs @@ -0,0 +1,259 @@ +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 diagnostics = stream.Diagnostics.Concat(observationDiagnostics) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return new SvSubscriberStreamEvidence + { + Key = stream.Key, + Health = stream.Health, + HealthDetail = stream.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 = 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 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"; + } +} From 4cbd2b7f4f3d390fbf9a62c8c4440082044b6ccf Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:38:01 +0700 Subject: [PATCH 22/39] Export paired Markdown and JSON SV evidence reports --- .../ViewModels/SvSubscriberViewModel.cs | 115 +++++++----------- 1 file changed, 43 insertions(+), 72 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs index 18ed11f..0330b63 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; @@ -481,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); } From ef1d1ba11bc1502ad140be0171d10cb2b9cadfb2 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:38:40 +0700 Subject: [PATCH 23/39] Test SV evidence report JSON and Markdown contracts --- .../SvSubscriberEvidenceReportTests.cs | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceReportTests.cs 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."] + } + ] + }; + } +} From 8ce308525fd6a290c4dfd22b92773d0aa9936515 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:39:01 +0700 Subject: [PATCH 24/39] Embed CI source revision in application informational version --- Directory.Build.props | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 + From 22b62c74b4d851010f6b8f4e34ac9715376829fa Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:39:27 +0700 Subject: [PATCH 25/39] Document Subscriber evidence report contract --- docs/sv-profile-infrastructure.md | 32 +++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index 7eb0547..4685c5d 100644 --- a/docs/sv-profile-infrastructure.md +++ b/docs/sv-profile-infrastructure.md @@ -96,7 +96,7 @@ Before accepting an SCL candidate, the observation manager requires APPID, desti ## Subscriber compact state -The selected stream now exposes one compact analysis strip instead of permanent large evidence cards: +The selected stream exposes one compact analysis strip instead of permanent large evidence cards: ```text PROFILE Generic Layer-2 SV @@ -109,6 +109,29 @@ Detailed detector evidence, configuration findings, and observation diagnostics 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. + +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 values remain explicit `null` values in JSON and `unknown` values 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. + ## Current integration boundary `SvStreamObservationManager` and Subscriber now: @@ -122,10 +145,11 @@ Waveform, phasor, and RMS collections use one reset notification per UI refresh. - 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; and -- keep full-window waveform, phasor, and RMS visualization stable through bulk collection refreshes. +- expose detailed evidence only on demand; +- keep full-window waveform, phasor, and RMS visualization stable through bulk collection refreshes; and +- serialize the same observation, profile, configuration, provenance, source, and build evidence into paired Markdown and JSON reports. ## Next integration 1. Add profile-specific definitions only after source review and deterministic evidence. -2. Serialize profile, configuration, provenance, observation-window, and source evidence into Subscriber reports. +2. Add report-to-report comparison after the evidence schema has real capture history and compatibility requirements. From ff2282a8f444933bd5f2651432c75818b5dc0774 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 03:41:36 +0700 Subject: [PATCH 26/39] Clarify unknown values in SV report schema --- docs/sv-profile-infrastructure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index 4685c5d..441a409 100644 --- a/docs/sv-profile-infrastructure.md +++ b/docs/sv-profile-infrastructure.md @@ -128,7 +128,7 @@ Both files include: - expected SCL configuration, comparison mode, exact/warning/error summary, and field-level findings, - runtime and observation diagnostics. -Unknown values remain explicit `null` values in JSON and `unknown` values 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. +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. From 4b1c68271152dd5f69fed5a5ad70917b8f4ac192 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 04:37:35 +0700 Subject: [PATCH 27/39] Add P3F evidence report comparison engine --- .../SvSubscriberEvidenceComparison.cs | 827 ++++++++++++++++++ 1 file changed, 827 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs 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..2292be7 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs @@ -0,0 +1,827 @@ +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 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.Select(stream => stream.ComparisonKey).Distinct(StringComparer.Ordinal).Count() != Streams.Count) + throw new InvalidOperationException("SV comparison stream 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 comparison collection."); + + var allChanges = ReportChanges.Concat(Streams.SelectMany(stream => stream.Changes)).ToArray(); + if (Summary.InfoChangeCount != allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Info) || + Summary.WarningChangeCount != allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Warning) || + Summary.ErrorChangeCount != allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Error)) + { + throw new InvalidOperationException("SV comparison severity totals do not match the comparison 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 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 streamComparisons = CompareStreams(baseline.Streams, candidate.Streams); + var allChanges = reportChanges.Concat(streamComparisons.SelectMany(stream => stream.Changes)).ToArray(); + + var result = new SvSubscriberEvidenceComparison + { + GeneratedAt = generatedAt, + Baseline = ToReference(baseline), + Candidate = ToReference(candidate), + Summary = new SvEvidenceComparisonSummary + { + BaselineStreamCount = baseline.Streams.Count, + CandidateStreamCount = candidate.Streams.Count, + AddedStreamCount = streamComparisons.Count(stream => stream.Kind == SvEvidenceChangeKind.Added), + RemovedStreamCount = streamComparisons.Count(stream => stream.Kind == SvEvidenceChangeKind.Removed), + ChangedStreamCount = streamComparisons.Count(stream => stream.Kind == SvEvidenceChangeKind.Changed), + UnchangedStreamCount = streamComparisons.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) + }, + ReportChanges = reportChanges, + Streams = streamComparisons + }; + result.Validate(); + return result; + } + + private static IReadOnlyList CompareReportMetadata( + SvSubscriberEvidenceReport baseline, + SvSubscriberEvidenceReport candidate) + { + var changes = new List(); + AddTextChange(changes, "Report", "Schema version", baseline.SchemaVersion, candidate.SchemaVersion, + SvEvidenceChangeSeverity.Error, "Evidence schema changed; compatibility must be reviewed."); + AddTextChange(changes, "Software", "Product", baseline.Software.Product, candidate.Software.Product, + SvEvidenceChangeSeverity.Warning, "Product identity changed between reports."); + AddTextChange(changes, "Software", "Version", baseline.Software.Version, candidate.Software.Version, + SvEvidenceChangeSeverity.Info, "Software version changed."); + AddTextChange(changes, "Software", "Commit", baseline.Software.Commit, candidate.Software.Commit, + SvEvidenceChangeSeverity.Info, "Build commit changed."); + AddTextChange(changes, "Capture", "Source", baseline.Capture.Source, candidate.Capture.Source, + SvEvidenceChangeSeverity.Info, "Capture source changed."); + AddTextChange(changes, "Capture", "SCL path", baseline.Capture.SclPath, candidate.Capture.SclPath, + SvEvidenceChangeSeverity.Info, "SCL source changed."); + AddHealthChange(changes, "Report", baseline.Summary.Health, candidate.Summary.Health); + return changes; + } + + private static IReadOnlyList CompareStreams( + IReadOnlyList baselineStreams, + IReadOnlyList candidateStreams) + { + var result = new List(); + var candidateByKey = candidateStreams.ToDictionary(stream => stream.Key, StringComparer.Ordinal); + var usedCandidateKeys = new HashSet(StringComparer.Ordinal); + var unmatchedBaseline = new List(); + + foreach (var baseline in baselineStreams) + { + if (candidateByKey.TryGetValue(baseline.Key, out var exact)) + { + result.Add(ComparePair(baseline, exact)); + usedCandidateKeys.Add(exact.Key); + } + else + { + unmatchedBaseline.Add(baseline); + } + } + + var unmatchedCandidate = candidateStreams + .Where(stream => !usedCandidateKeys.Contains(stream.Key)) + .ToArray(); + var baselineLogicalGroups = unmatchedBaseline.GroupBy(LogicalKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var candidateLogicalGroups = unmatchedCandidate.GroupBy(LogicalKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + foreach (var baseline in unmatchedBaseline) + { + var logicalKey = LogicalKey(baseline); + if (baselineLogicalGroups[logicalKey].Length == 1 && + candidateLogicalGroups.TryGetValue(logicalKey, out var candidates) && + candidates.Length == 1 && + usedCandidateKeys.Add(candidates[0].Key)) + { + result.Add(ComparePair(baseline, candidates[0])); + continue; + } + + result.Add(Removed(baseline)); + } + + foreach (var candidate in candidateStreams.Where(stream => !usedCandidateKeys.Contains(stream.Key))) + result.Add(Added(candidate)); + + return result + .OrderBy(stream => stream.Identity.AppId) + .ThenBy(stream => stream.Identity.SvId, StringComparer.Ordinal) + .ThenBy(stream => stream.Kind) + .ToArray(); + } + + private static SvSubscriberStreamComparison ComparePair( + SvSubscriberStreamEvidence baseline, + SvSubscriberStreamEvidence candidate) + { + var changes = new List(); + AddHealthChange(changes, "Stream", baseline.Health, candidate.Health); + AddTextChange(changes, "Identity", "Source MAC", baseline.Identity.SourceMac, candidate.Identity.SourceMac, + SvEvidenceChangeSeverity.Info, "Publisher source MAC changed while the logical stream identity remained stable."); + AddNullableChange(changes, "Identity", "confRev", baseline.Identity.ConfigurationRevision, + candidate.Identity.ConfigurationRevision, SvEvidenceChangeSeverity.Warning, "Configuration revision changed."); + AddNullableChange(changes, "Identity", "ASDU per frame", baseline.Identity.AsduPerFrame, + candidate.Identity.AsduPerFrame, SvEvidenceChangeSeverity.Warning, "ASDU packing changed."); + AddNullableChange(changes, "Identity", "Declared sample rate", baseline.Identity.DeclaredSampleRate, + candidate.Identity.DeclaredSampleRate, SvEvidenceChangeSeverity.Warning, "Declared sample rate changed."); + AddNullableChange(changes, "Identity", "Declared sample mode", baseline.Identity.DeclaredSampleMode, + candidate.Identity.DeclaredSampleMode, SvEvidenceChangeSeverity.Warning, "Declared sample mode changed."); + + CompareIssueCounter(changes, "Sequence gaps", baseline.Runtime.SequenceGapCount, candidate.Runtime.SequenceGapCount, + SvEvidenceChangeSeverity.Warning); + CompareIssueCounter(changes, "Duplicates", baseline.Runtime.DuplicateCount, candidate.Runtime.DuplicateCount, + SvEvidenceChangeSeverity.Warning); + CompareIssueCounter(changes, "Out-of-order", baseline.Runtime.OutOfOrderCount, candidate.Runtime.OutOfOrderCount, + SvEvidenceChangeSeverity.Error); + CompareIssueCounter(changes, "Payload issues", baseline.Runtime.PayloadIssueCount, candidate.Runtime.PayloadIssueCount, + SvEvidenceChangeSeverity.Error); + CompareIssueCounter(changes, "SCL mismatches", baseline.Runtime.SclMismatchCount, candidate.Runtime.SclMismatchCount, + SvEvidenceChangeSeverity.Warning); + CompareRate(changes, "Observed frames/s", baseline.Observation.Facts.ObservedFramesPerSecond, + candidate.Observation.Facts.ObservedFramesPerSecond); + CompareRate(changes, "Observed samples/s", baseline.Observation.Facts.ObservedSamplesPerSecond, + candidate.Observation.Facts.ObservedSamplesPerSecond); + CompareWindow(changes, baseline.Observation, candidate.Observation); + CompareBinding(changes, baseline.Observation, candidate.Observation); + CompareProfile(changes, baseline.Observation.ProfileDetection, candidate.Observation.ProfileDetection); + CompareConfiguration(changes, baseline.Observation.ConfigurationComparison, + candidate.Observation.ConfigurationComparison); + CompareFacts(changes, baseline.Observation.Facts, candidate.Observation.Facts); + CompareDiagnostics(changes, baseline.Diagnostics.Concat(baseline.Observation.Diagnostics), + candidate.Diagnostics.Concat(candidate.Observation.Diagnostics)); + + var kind = changes.Count == 0 ? SvEvidenceChangeKind.Unchanged : SvEvidenceChangeKind.Changed; + return new SvSubscriberStreamComparison + { + ComparisonKey = LogicalKey(candidate), + Kind = kind, + Severity = MaximumSeverity(changes), + BaselineStreamKey = baseline.Key, + CandidateStreamKey = candidate.Key, + Identity = candidate.Identity, + Changes = changes + }; + } + + private static void CompareWindow( + 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; + Add(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 (!ApproximatelyEqual(baseline.WindowDurationSeconds, candidate.WindowDurationSeconds, 0.01)) + { + Add(changes, "Observation window", "Duration", SvEvidenceChangeSeverity.Info, + Number(baseline.WindowDurationSeconds), Number(candidate.WindowDurationSeconds), + "Observation-window duration changed."); + } + } + + private static void CompareBinding( + ICollection changes, + SvSubscriberObservationEvidence baseline, + SvSubscriberObservationEvidence candidate) + { + if (baseline.IsBoundToScl != candidate.IsBoundToScl) + { + var severity = baseline.IsBoundToScl && !candidate.IsBoundToScl + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Add(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."); + } + + AddTextChange(changes, "SCL", "Control block", baseline.ControlBlockReference, + candidate.ControlBlockReference, SvEvidenceChangeSeverity.Info, + "SCL control-block reference changed."); + } + + private static void CompareProfile( + ICollection changes, + SvProfileDetectionResult? baseline, + SvProfileDetectionResult? candidate) + { + AddTextChange(changes, "Profile", "Profile ID", baseline?.Profile.Id ?? string.Empty, + candidate?.Profile.Id ?? string.Empty, 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; + Add(changes, "Profile", "Confidence", severity, baselineConfidence.ToString(), + candidateConfidence.ToString(), + severity == SvEvidenceChangeSeverity.Error + ? "Candidate profile classification is conflicting." + : severity == SvEvidenceChangeSeverity.Warning + ? "Candidate profile confidence decreased." + : "Candidate profile confidence improved."); + } + + private static void CompareConfiguration( + 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 introducedBlocking = candidate?.HasBlockingErrors == true && baseline?.HasBlockingErrors != true; + var warningIncrease = (candidate?.WarningCount ?? 0) > (baseline?.WarningCount ?? 0); + var severity = introducedBlocking + ? SvEvidenceChangeSeverity.Error + : warningIncrease || (baseline is not null && candidate is null) + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Add(changes, "Configuration", "Comparison", severity, baselineSummary, candidateSummary, + introducedBlocking + ? "Candidate introduced blocking configuration errors." + : severity == SvEvidenceChangeSeverity.Warning + ? "Candidate configuration evidence regressed." + : "Configuration comparison result changed."); + } + + private static void CompareFacts( + ICollection changes, + SvObservedStreamFacts baseline, + SvObservedStreamFacts candidate) + { + AddNullableChange(changes, "Observed facts", "Payload bytes per ASDU", + baseline.PayloadBytesPerAsdu, candidate.PayloadBytesPerAsdu, + SvEvidenceChangeSeverity.Warning, "Observed payload length changed."); + AddNullableChange(changes, "Observed facts", "Counter wrap", baseline.ObservedCounterWrap, + candidate.ObservedCounterWrap, SvEvidenceChangeSeverity.Warning, + "Observed sample-counter wrap changed."); + AddNullableChange(changes, "Observed facts", "Nominal frequency", baseline.NominalFrequencyHz, + candidate.NominalFrequencyHz, SvEvidenceChangeSeverity.Warning, + "Nominal-frequency context changed."); + + var baselineSignature = Signature(baseline.DataSetSignature); + var candidateSignature = Signature(candidate.DataSetSignature); + AddTextChange(changes, "Observed facts", "Dataset signature", baselineSignature, + candidateSignature, SvEvidenceChangeSeverity.Error, + "Observed dataset element order or types changed."); + + var provenanceKeys = baseline.Provenance.Keys.Concat(candidate.Provenance.Keys) + .Distinct(StringComparer.Ordinal); + foreach (var key in provenanceKeys) + { + 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) + { + Add(changes, "Provenance", key, SvEvidenceChangeSeverity.Info, + baselineSource.ToString(), candidateSource.ToString(), + "Fact provenance changed."); + } + } + } + + private static void CompareDiagnostics( + ICollection changes, + IEnumerable baseline, + IEnumerable candidate) + { + var baselineSet = baseline.Where(item => !string.IsNullOrWhiteSpace(item)) + .ToHashSet(StringComparer.Ordinal); + var candidateSet = candidate.Where(item => !string.IsNullOrWhiteSpace(item)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var added in candidateSet.Except(baselineSet, StringComparer.Ordinal).Order(StringComparer.Ordinal)) + Add(changes, "Diagnostics", "Added", SvEvidenceChangeSeverity.Warning, "-", added, + "Candidate introduced a diagnostic."); + foreach (var removed in baselineSet.Except(candidateSet, StringComparer.Ordinal).Order(StringComparer.Ordinal)) + Add(changes, "Diagnostics", "Resolved", SvEvidenceChangeSeverity.Info, removed, "-", + "A baseline diagnostic is no longer present."); + } + + private static void CompareIssueCounter( + ICollection changes, + string field, + int baseline, + int candidate, + SvEvidenceChangeSeverity regressionSeverity) + { + if (baseline == candidate) + return; + var severity = candidate > baseline ? regressionSeverity : SvEvidenceChangeSeverity.Info; + Add(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 CompareRate( + ICollection changes, + string field, + double? baseline, + double? candidate) + { + if (!baseline.HasValue && !candidate.HasValue) + return; + if (!baseline.HasValue || !candidate.HasValue) + { + Add(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning, + Number(baseline), Number(candidate), "Observed rate availability changed."); + return; + } + if (ApproximatelyEqual(baseline.Value, candidate.Value, RateTolerancePercent)) + return; + + Add(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning, + Number(baseline), Number(candidate), + $"Observed rate changed by more than {RateTolerancePercent:0.###}%."); + } + + private static void AddHealthChange( + ICollection changes, + string category, + string baseline, + string candidate) + { + if (string.Equals(baseline, candidate, StringComparison.OrdinalIgnoreCase)) + return; + var severity = HealthRank(candidate) > HealthRank(baseline) + ? HealthRank(candidate) >= 2 ? SvEvidenceChangeSeverity.Error : SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Add(changes, category, "Health", severity, baseline, candidate, + severity == SvEvidenceChangeSeverity.Info ? "Health improved or changed without regression." : "Health regressed."); + } + + private static void AddTextChange( + 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)) + Add(changes, category, field, severity, Empty(baseline), Empty(candidate), message); + } + + private static void AddNullableChange( + ICollection changes, + string category, + string field, + T? baseline, + T? candidate, + SvEvidenceChangeSeverity severity, + string message) + where T : struct, IEquatable + { + if (!Nullable.Equals(baseline, candidate)) + Add(changes, category, field, severity, Value(baseline), Value(candidate), message); + } + + private static void Add( + 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) + => new() + { + ComparisonKey = LogicalKey(stream), + 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) + => new() + { + ComparisonKey = LogicalKey(stream), + 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 ToReference(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 SvEvidenceChangeSeverity MaximumSeverity(IEnumerable changes) + => changes.Select(change => change.Severity).DefaultIfEmpty(SvEvidenceChangeSeverity.Info).Max(); + + 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}|" + + $"{NormalizeIdentifier(identity.SvId)}|{NormalizeIdentifier(identity.DataSetReference)}"; + } + + private static string NormalizeMac(string value) + => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); + + private static string NormalizeIdentifier(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 ApproximatelyEqual(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 Empty(string? value) + => string.IsNullOrWhiteSpace(value) ? "unknown" : value; +} + +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(); + AppendKeyValueTable(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(); + AppendKeyValueTable(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") + ]); + + AppendChanges(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(); + AppendKeyValueTable(builder, + [ + ("Kind", stream.Kind.ToString()), + ("Severity", stream.Severity.ToString()), + ("Logical 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) + ]); + AppendChanges(builder, "Changes", stream.Changes); + } + + return builder.ToString(); + } + + private static void AppendChanges( + 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 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 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); +} From b1bf78e6c0eb22dde027ac9ec62e9cf037231946 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 04:38:09 +0700 Subject: [PATCH 28/39] Add deterministic P3F comparison tests --- .../SvSubscriberEvidenceComparisonTests.cs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvSubscriberEvidenceComparisonTests.cs 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 + } + }; + } +} From 3d1ec20c5f92607f5514a45e3f81c4d82df586a8 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 04:38:57 +0700 Subject: [PATCH 29/39] Add P3F evidence comparison action --- src/ARSVIN.Subscriber/MainWindow.xaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml index 11a9f7f..27d69bb 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml +++ b/src/ARSVIN.Subscriber/MainWindow.xaml @@ -102,6 +102,7 @@