diff --git a/Directory.Build.props b/Directory.Build.props index b562075..d584239 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,6 +9,8 @@ true true 0.4.0 + $(GITHUB_SHA) + true https://github.com/masarray/arsvin git GPL-3.0-or-later @@ -16,4 +18,4 @@ Ari Sulistiono Copyright © 2026 Ari Sulistiono - \ No newline at end of file + diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index d0d8ff5..9935f9b 100644 --- a/docs/sv-profile-infrastructure.md +++ b/docs/sv-profile-infrastructure.md @@ -15,7 +15,7 @@ Imported PCAP ────┘ ↓ └── SvConfigurationComparer ``` -The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract. +The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract. ## Observed facts @@ -75,30 +75,106 @@ A high score is an engineering classification result, not a conformance certific ## Configuration-versus-wire comparison -`SvConfigurationComparer` compares configured expectations with observed facts for addressing, identifiers, dataset, revision, packing, payload, and declared sampling fields. +`SvExpectedStreamConfigurationFactory` converts the SCL-bound `SampledValuesPublisherProfile` into a transport-neutral `SvExpectedStreamConfiguration`. The expected configuration includes addressing, identifiers, revision, packing, payload length, declared sampling fields, and ordered dataset signature. + +`SvStreamObservationManager` compares that expected configuration with the rolling `SvObservedStreamFacts` for every bound live or PCAP stream. The resulting immutable snapshot carries: + +- the expected configuration, +- field-level findings, +- comparison mode, +- exact/warning/error counts, +- a compact summary such as `Exact`, `2 warnings`, or `1 error`. Two modes are available: -- **Strict** — mismatches become errors suitable for validation and live-transmission preflight. -- **Compatible** — mismatches become warnings suitable for troubleshooting and unfamiliar devices. +- **Compatible** — the Subscriber default; mismatches become warnings suitable for troubleshooting and unfamiliar devices. +- **Strict** — explicit opt-in for validation, preflight, and formal test cases; mismatches become errors. Neither mode stops receive-side capture or decoding. Unknown and conflicting streams remain visible. -## Current integration boundary +Before accepting an SCL candidate, the observation manager requires APPID, destination MAC, and VLAN to identify the same configured stream. A candidate that fails this address gate is rejected instead of contaminating observed facts with the wrong dataset layout. + +## Subscriber compact state + +The selected stream exposes one compact analysis strip instead of permanent large evidence cards: + +```text +PROFILE Generic Layer-2 SV +CONFIDENCE Unknown · insufficient evidence +SCL MATCH Exact | N warnings | N errors +WINDOW duration · observed samples +``` + +Detailed detector evidence, configuration findings, and observation diagnostics remain collapsed behind an expandable evidence panel. Capture and visualization continue without requiring repeated manual selection. + +Waveform, phasor, and RMS collections use one reset notification per UI refresh. The visual layer withholds partial waveform windows until a complete two-cycle set is available, then retains the most recent complete window if the next refresh is incomplete. Compatible SCL warnings remain warnings and do not force the stream into a blocking `BAD` state. + +## Evidence report bundle + +Subscriber Export creates two files from the same report snapshot: + +- a Markdown engineering report for review and handover, +- a JSON evidence document using schema `arsvin.sv-subscriber-evidence/v1` for automation, archival, and later comparison. -`SvStreamObservationManager` now: +Both files include: + +- generation time, product version, informational version, repository, and source commit, +- live, PCAP, or mixed input provenance, +- SCL path, adapter, user filter, capture duration, frame counts, parse errors, and filtered-frame counts, +- per-stream identity, runtime integrity, quality, cursor state, waveform readiness, and phasors, +- first and last observation timestamps, frame and sample counts, measured rates, sample-counter transitions, and wrap evidence, +- every stable observed fact with its provenance (`WireObserved`, `CaptureCalculated`, `SclDerived`, or trusted context), +- profile definition, evidence maturity, confidence, weighted match evidence, and source metadata, +- expected SCL configuration, comparison mode, exact/warning/error summary, and field-level findings, +- runtime and observation diagnostics. + +Unknown nullable values remain explicit `null`; unknown text fields remain empty strings in JSON and render as `unknown` in Markdown. They are not silently removed or interpreted as matches. The report schema validates generation time, product identity, stream count, and unique stream keys before serialization. + +GitHub Actions injects `GITHUB_SHA` into `SourceRevisionId`; the .NET informational version therefore carries the build commit for release and CI artifacts. Local builds without source revision metadata report the commit as `unknown` rather than inventing one. + +## Evidence report comparison + +The Subscriber **Compare** action accepts a baseline and candidate `arsvin.sv-subscriber-evidence/v1` JSON report and writes a paired comparison bundle: + +- Markdown for engineering review, +- JSON using schema `arsvin.sv-subscriber-evidence-comparison/v1` for automation and regression gates. + +Comparison uses the full stable stream key first. Unmatched streams are then paired through a logical identity made from APPID, destination MAC, VLAN, `svID`, and dataset reference. Source MAC is deliberately excluded from that fallback identity so publisher/NIC failover is reported as a source change rather than a false removed-plus-added stream. + +The comparison classifies: + +- report-level schema, software, commit, capture-source, SCL-source, and health changes, +- added, removed, changed, and unchanged logical streams, +- health regression or recovery, +- source-MAC failover, +- `confRev`, ASDU packing, sample-rate, and sample-mode changes, +- sequence gaps, duplicates, out-of-order frames, payload issues, and SCL mismatches, +- observation-window size and measured-rate changes, +- SCL binding and configuration-comparison regression, +- profile identity and confidence changes, +- payload, counter-wrap, nominal-frequency, dataset-signature, provenance, and diagnostic changes. + +Changes are assigned `Info`, `Warning`, or `Error` severity. Removed streams, health transitions to `BAD`/`ERROR`, new out-of-order or payload failures, blocking configuration errors, profile conflicts, and dataset-signature changes are treated as high-signal regressions. Measured-rate changes within the one-percent comparison tolerance do not create false warnings. + +## Current integration boundary -- accepts parsed frames from live Npcap capture and classic-PCAP replay; -- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference; -- deliberately keeps `confRev` outside the key so revision changes remain observable; -- retains input provenance (`LiveCapture` or `PcapReplay`); -- adds SCL-derived dataset signatures when a stream is bound; and -- exposes immutable rolling facts to the Subscriber runtime snapshot. +`SvStreamObservationManager` and Subscriber now: + +- accept parsed frames from live Npcap capture and classic-PCAP replay; +- group frames by source, destination, VLAN, APPID, `svID`, and dataset reference; +- deliberately keep `confRev` outside the key so revision changes remain observable; +- retain input provenance (`LiveCapture` or `PcapReplay`); +- validate the SCL candidate against the observed address tuple; +- convert a bound SCL stream into `SvExpectedStreamConfiguration`; +- run Compatible comparison by default, with Strict available per observation call; +- evaluate the built-in evidence-aware profile catalog; +- expose compact profile, confidence, SCL match, and observation-window state; +- expose detailed evidence only on demand; +- keep full-window waveform, phasor, and RMS visualization stable through bulk collection refreshes; +- serialize the same observation, profile, configuration, provenance, source, and build evidence into paired Markdown and JSON reports; and +- compare baseline and candidate evidence reports through deterministic, severity-ranked Markdown and JSON output. ## Next integration -1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`. -2. Run strict or compatible configuration comparison per observed stream. -3. Present one compact profile/confidence and SCL-match state per selected stream. -4. Show evidence and mismatch details on demand rather than adding permanent visual noise. -5. Add profile-specific definitions only after source review and deterministic evidence. +1. Add profile-specific definitions only after source review and deterministic evidence. +2. Accumulate real comparison history before defining organization-specific pass/fail policy or CI blocking thresholds. diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs index 27872d1..f0bf364 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs @@ -46,9 +46,27 @@ public sealed record SvConfigurationComparisonResult = Array.Empty(); public bool HasBlockingErrors => Findings.Any(item => item.Severity == SvConfigurationFindingSeverity.Error); + public int InfoCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Info); public int ErrorCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Error); public int WarningCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Warning); public bool IsExactMatch => Findings.Count == 0; + + public string Summary + { + get + { + if (IsExactMatch) + return "Exact"; + if (ErrorCount > 0) + return CountText(ErrorCount, "error"); + if (WarningCount > 0) + return CountText(WarningCount, "warning"); + return CountText(InfoCount, "info"); + } + } + + private static string CountText(int count, string label) + => $"{count} {label}{(count == 1 ? string.Empty : "s")}"; } public sealed class SvConfigurationComparer diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs new file mode 100644 index 0000000..8a799a3 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs @@ -0,0 +1,54 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues.Profiles; + +public static class SvExpectedStreamConfigurationFactory +{ + public static SvExpectedStreamConfiguration Create(SampledValuesPublisherProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + return new SvExpectedStreamConfiguration + { + EtherType = 0x88BA, + AppId = profile.AppId, + DestinationMac = profile.Destination.ToString(), + VlanId = profile.Vlan?.VlanId, + VlanPriority = profile.Vlan?.PriorityCodePoint, + SvId = profile.Stream.SvId, + DataSetReference = profile.Stream.DataSetReference, + ConfigurationRevision = profile.Stream.ConfigurationRevision, + AsduPerFrame = profile.AsduPerFrame, + PayloadBytesPerAsdu = profile.PayloadLayout.PayloadByteLength, + DeclaredSampleRate = profile.Stream.SampleRate == 0 + ? null + : profile.Stream.SampleRate, + DeclaredSampleMode = MapSampleMode(profile.Stream.SampleMode), + DataSetSignature = profile.Entries.Select(ToSignature).ToArray() + }; + } + + private static ushort? MapSampleMode(string sampleMode) + { + if (string.IsNullOrWhiteSpace(sampleMode)) + return null; + + return sampleMode.Trim() switch + { + "SmpPerPeriod" => 0, + "SmpPerSec" => 1, + "SecPerSmp" => 2, + _ => null + }; + } + + private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry) + => new() + { + BType = entry.BType, + Cdc = entry.Cdc, + IsQuality = entry.IsQuality, + IsTimestamp = entry.IsTimestamp + }; +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 96b8b3b..9b43c2f 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -55,16 +55,25 @@ public sealed record SvStreamObservationSnapshot public SvObservationInputKind LastInputKind { get; init; } public bool IsBoundToScl { get; init; } public string ControlBlockReference { get; init; } = string.Empty; + public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; } + public SvConfigurationComparisonResult? ConfigurationComparison { get; init; } + public SvProfileDetectionResult? ProfileDetection { get; init; } + public string ConfigurationMatchSummary => ConfigurationComparison?.Summary ?? "Not configured"; public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); } public sealed class SvStreamObservationManager { + public const int DefaultMaximumObservations = 12_288; + public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(2); + private sealed class StreamState { private readonly object _gate = new(); private readonly HashSet _inputKinds = []; private readonly Queue _diagnostics = new(); + private SvExpectedStreamConfiguration? _expectedConfiguration; + private SvComparisonMode _comparisonMode = SvComparisonMode.Compatible; public StreamState(int maximumObservations, TimeSpan maximumAge) { @@ -80,6 +89,7 @@ public void Add( SvFrameObservation observation, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, + SvComparisonMode comparisonMode, IEnumerable diagnostics) { Accumulator.Add(observation); @@ -91,6 +101,8 @@ public void Add( { IsBoundToScl = true; ControlBlockReference = profile.Stream.ControlBlockReference; + _expectedConfiguration = SvExpectedStreamConfigurationFactory.Create(profile); + _comparisonMode = comparisonMode; } foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item))) @@ -110,6 +122,16 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) var facts = Accumulator.BuildFacts(); lock (_gate) { + var comparison = _expectedConfiguration is null + ? null + : new SvConfigurationComparer().Compare( + _expectedConfiguration, + facts, + _comparisonMode); + var profileDetection = new SvProfileDetector().DetectBest( + facts, + SvProfileCatalog.BuiltIn); + return new SvStreamObservationSnapshot { Key = key, @@ -118,6 +140,9 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) LastInputKind = LastInputKind, IsBoundToScl = IsBoundToScl, ControlBlockReference = ControlBlockReference, + ExpectedConfiguration = _expectedConfiguration, + ConfigurationComparison = comparison, + ProfileDetection = profileDetection, Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() }; } @@ -129,13 +154,13 @@ public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) private readonly TimeSpan _maximumAge; public SvStreamObservationManager( - int maximumObservations = SvObservationAccumulator.DefaultMaximumObservations, + int maximumObservations = DefaultMaximumObservations, TimeSpan? maximumAge = null) { if (maximumObservations < 2) throw new ArgumentOutOfRangeException(nameof(maximumObservations)); - _maximumAge = maximumAge ?? SvObservationAccumulator.DefaultMaximumAge; + _maximumAge = maximumAge ?? DefaultMaximumAge; if (_maximumAge <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(maximumAge)); @@ -150,7 +175,8 @@ public bool TryObserve( SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, out SvStreamObservationSnapshot snapshot, - double? nominalFrequencyHz = null) + double? nominalFrequencyHz = null, + SvComparisonMode comparisonMode = SvComparisonMode.Compatible) { ArgumentNullException.ThrowIfNull(frame); snapshot = new SvStreamObservationSnapshot(); @@ -161,8 +187,9 @@ public bool TryObserve( return false; var key = SvObservedStreamKey.FromFrame(frame); - var diagnostics = ValidateFrameConsistency(asdus); - var signature = profile?.Entries.Select(ToSignature).ToArray() + var diagnostics = ValidateFrameConsistency(asdus).ToList(); + var boundProfile = ValidateProfileBinding(frame, profile, diagnostics); + var signature = boundProfile?.Entries.Select(ToSignature).ToArray() ?? Array.Empty(); var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray(); var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length; @@ -189,7 +216,7 @@ public bool TryObserve( var state = _streams.GetOrAdd( key, _ => new StreamState(_maximumObservations, _maximumAge)); - state.Add(observation, inputKind, profile, diagnostics); + state.Add(observation, inputKind, boundProfile, comparisonMode, diagnostics); snapshot = state.Snapshot(key); return true; } @@ -216,6 +243,29 @@ public IReadOnlyList SnapshotAll() public void Clear() => _streams.Clear(); + private static SampledValuesPublisherProfile? ValidateProfileBinding( + SampledValuesFrame frame, + SampledValuesPublisherProfile? profile, + ICollection diagnostics) + { + if (profile is null) + return null; + + var appIdMatches = profile.AppId == frame.AppId; + var destinationMatches = string.Equals( + profile.Destination.ToString(), + frame.Destination.ToString(), + StringComparison.OrdinalIgnoreCase); + var vlanMatches = profile.Vlan?.VlanId == frame.Vlan?.VlanId; + if (appIdMatches && destinationMatches && vlanMatches) + return profile; + + diagnostics.Add( + $"Rejected SCL candidate {profile.Stream.ControlBlockReference}: " + + "APPID, destination MAC, and VLAN must identify the same configured stream before comparison."); + return null; + } + private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList asdus) { var diagnostics = new List(); diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs new file mode 100644 index 0000000..7215394 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs @@ -0,0 +1,708 @@ +using System.Globalization; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Reporting; + +public enum SvEvidenceChangeKind +{ + Added, + Removed, + Changed, + Unchanged +} + +public enum SvEvidenceChangeSeverity +{ + Info, + Warning, + Error +} + +public sealed record SvSubscriberEvidenceComparison +{ + public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence-comparison/v1"; + + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset GeneratedAt { get; init; } + public SvEvidenceReportReference Baseline { get; init; } = new(); + public SvEvidenceReportReference Candidate { get; init; } = new(); + public SvEvidenceComparisonSummary Summary { get; init; } = new(); + public IReadOnlyList ReportChanges { get; init; } + = Array.Empty(); + public IReadOnlyList Streams { get; init; } + = Array.Empty(); + + public void Validate() + { + if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal)) + throw new InvalidOperationException($"Unsupported SV comparison schema '{SchemaVersion}'."); + if (GeneratedAt == default) + throw new InvalidOperationException("SV comparison requires a generation timestamp."); + if (string.IsNullOrWhiteSpace(Baseline.SchemaVersion) || string.IsNullOrWhiteSpace(Candidate.SchemaVersion)) + throw new InvalidOperationException("SV comparison requires baseline and candidate schema metadata."); + if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.ComparisonKey))) + throw new InvalidOperationException("Every stream comparison requires a stable comparison key."); + if (Streams.Select(stream => stream.ComparisonKey).Distinct(StringComparer.Ordinal).Count() != Streams.Count) + throw new InvalidOperationException("SV comparison keys must be unique."); + + var classified = Summary.AddedStreamCount + Summary.RemovedStreamCount + + Summary.ChangedStreamCount + Summary.UnchangedStreamCount; + if (classified != Streams.Count) + throw new InvalidOperationException("SV comparison summary does not match the stream collection."); + + var changes = ReportChanges.Concat(Streams.SelectMany(stream => stream.Changes)).ToArray(); + if (Summary.InfoChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Info) || + Summary.WarningChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Warning) || + Summary.ErrorChangeCount != changes.Count(change => change.Severity == SvEvidenceChangeSeverity.Error)) + { + throw new InvalidOperationException("SV comparison severity totals do not match the evidence."); + } + } +} + +public sealed record SvEvidenceReportReference +{ + public string SchemaVersion { get; init; } = string.Empty; + public DateTimeOffset GeneratedAt { get; init; } + public string Product { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public string Commit { get; init; } = string.Empty; + public string CaptureSource { get; init; } = string.Empty; + public string Health { get; init; } = string.Empty; + public int StreamCount { get; init; } +} + +public sealed record SvEvidenceComparisonSummary +{ + public int BaselineStreamCount { get; init; } + public int CandidateStreamCount { get; init; } + public int AddedStreamCount { get; init; } + public int RemovedStreamCount { get; init; } + public int ChangedStreamCount { get; init; } + public int UnchangedStreamCount { get; init; } + public int InfoChangeCount { get; init; } + public int WarningChangeCount { get; init; } + public int ErrorChangeCount { get; init; } + public bool HasRegressions => WarningChangeCount > 0 || ErrorChangeCount > 0; +} + +public sealed record SvSubscriberStreamComparison +{ + public string ComparisonKey { get; init; } = string.Empty; + public string LogicalStreamKey { get; init; } = string.Empty; + public SvEvidenceChangeKind Kind { get; init; } + public SvEvidenceChangeSeverity Severity { get; init; } + public string BaselineStreamKey { get; init; } = string.Empty; + public string CandidateStreamKey { get; init; } = string.Empty; + public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new(); + public IReadOnlyList Changes { get; init; } + = Array.Empty(); +} + +public sealed record SvEvidenceFieldChange +{ + public string Category { get; init; } = string.Empty; + public string Field { get; init; } = string.Empty; + public SvEvidenceChangeSeverity Severity { get; init; } + public string Baseline { get; init; } = string.Empty; + public string Candidate { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; +} + +public sealed class SvSubscriberEvidenceComparator +{ + private const double RateTolerancePercent = 1.0; + + public SvSubscriberEvidenceComparison Compare( + SvSubscriberEvidenceReport baseline, + SvSubscriberEvidenceReport candidate, + DateTimeOffset generatedAt) + { + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(candidate); + baseline.Validate(); + candidate.Validate(); + if (generatedAt == default) + throw new ArgumentException("Comparison requires a generation timestamp.", nameof(generatedAt)); + + var reportChanges = CompareReportMetadata(baseline, candidate); + var streams = CompareStreams(baseline.Streams, candidate.Streams); + var allChanges = reportChanges.Concat(streams.SelectMany(stream => stream.Changes)).ToArray(); + var comparison = new SvSubscriberEvidenceComparison + { + GeneratedAt = generatedAt, + Baseline = Reference(baseline), + Candidate = Reference(candidate), + ReportChanges = reportChanges, + Streams = streams, + Summary = new SvEvidenceComparisonSummary + { + BaselineStreamCount = baseline.Streams.Count, + CandidateStreamCount = candidate.Streams.Count, + AddedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Added), + RemovedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Removed), + ChangedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Changed), + UnchangedStreamCount = streams.Count(stream => stream.Kind == SvEvidenceChangeKind.Unchanged), + InfoChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Info), + WarningChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Warning), + ErrorChangeCount = allChanges.Count(change => change.Severity == SvEvidenceChangeSeverity.Error) + } + }; + comparison.Validate(); + return comparison; + } + + private static IReadOnlyList CompareReportMetadata( + SvSubscriberEvidenceReport baseline, + SvSubscriberEvidenceReport candidate) + { + var changes = new List(); + TextChange(changes, "Report", "Schema version", baseline.SchemaVersion, candidate.SchemaVersion, + SvEvidenceChangeSeverity.Error, "Evidence schema changed; compatibility must be reviewed."); + TextChange(changes, "Software", "Product", baseline.Software.Product, candidate.Software.Product, + SvEvidenceChangeSeverity.Warning, "Product identity changed."); + TextChange(changes, "Software", "Version", baseline.Software.Version, candidate.Software.Version, + SvEvidenceChangeSeverity.Info, "Software version changed."); + TextChange(changes, "Software", "Commit", baseline.Software.Commit, candidate.Software.Commit, + SvEvidenceChangeSeverity.Info, "Build commit changed."); + TextChange(changes, "Capture", "Source", baseline.Capture.Source, candidate.Capture.Source, + SvEvidenceChangeSeverity.Info, "Capture source changed."); + TextChange(changes, "Capture", "SCL path", baseline.Capture.SclPath, candidate.Capture.SclPath, + SvEvidenceChangeSeverity.Info, "SCL source changed."); + HealthChange(changes, "Report", baseline.Summary.Health, candidate.Summary.Health); + return changes; + } + + private static IReadOnlyList CompareStreams( + IReadOnlyList baseline, + IReadOnlyList candidate) + { + var comparisons = new List(); + var candidateByKey = candidate.ToDictionary(stream => stream.Key, StringComparer.Ordinal); + var usedCandidateKeys = new HashSet(StringComparer.Ordinal); + var unmatchedBaseline = new List(); + + foreach (var baselineStream in baseline) + { + if (candidateByKey.TryGetValue(baselineStream.Key, out var exact)) + { + comparisons.Add(Pair(baselineStream, exact)); + usedCandidateKeys.Add(exact.Key); + } + else + { + unmatchedBaseline.Add(baselineStream); + } + } + + var unmatchedCandidate = candidate.Where(stream => !usedCandidateKeys.Contains(stream.Key)).ToArray(); + var baselineLogical = unmatchedBaseline.GroupBy(LogicalKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var candidateLogical = unmatchedCandidate.GroupBy(LogicalKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + foreach (var baselineStream in unmatchedBaseline) + { + var logicalKey = LogicalKey(baselineStream); + if (baselineLogical[logicalKey].Length == 1 && + candidateLogical.TryGetValue(logicalKey, out var candidates) && + candidates.Length == 1 && + usedCandidateKeys.Add(candidates[0].Key)) + { + comparisons.Add(Pair(baselineStream, candidates[0])); + } + else + { + comparisons.Add(Removed(baselineStream)); + } + } + + foreach (var candidateStream in candidate.Where(stream => !usedCandidateKeys.Contains(stream.Key))) + comparisons.Add(Added(candidateStream)); + + return comparisons.OrderBy(stream => stream.Identity.AppId) + .ThenBy(stream => stream.Identity.SvId, StringComparer.Ordinal) + .ThenBy(stream => stream.Kind) + .ThenBy(stream => stream.ComparisonKey, StringComparer.Ordinal) + .ToArray(); + } + + private static SvSubscriberStreamComparison Pair( + SvSubscriberStreamEvidence baseline, + SvSubscriberStreamEvidence candidate) + { + var changes = new List(); + HealthChange(changes, "Stream", baseline.Health, candidate.Health); + TextChange(changes, "Identity", "Source MAC", baseline.Identity.SourceMac, candidate.Identity.SourceMac, + SvEvidenceChangeSeverity.Info, "Publisher source MAC changed while logical identity remained stable."); + UIntChange(changes, "Identity", "confRev", baseline.Identity.ConfigurationRevision, + candidate.Identity.ConfigurationRevision, SvEvidenceChangeSeverity.Warning, "Configuration revision changed."); + IntChange(changes, "Identity", "ASDU per frame", baseline.Identity.AsduPerFrame, + candidate.Identity.AsduPerFrame, SvEvidenceChangeSeverity.Warning, "ASDU packing changed."); + UShortChange(changes, "Identity", "Declared sample rate", baseline.Identity.DeclaredSampleRate, + candidate.Identity.DeclaredSampleRate, SvEvidenceChangeSeverity.Warning, "Declared sample rate changed."); + UShortChange(changes, "Identity", "Declared sample mode", baseline.Identity.DeclaredSampleMode, + candidate.Identity.DeclaredSampleMode, SvEvidenceChangeSeverity.Warning, "Declared sample mode changed."); + + IssueCounter(changes, "Sequence gaps", baseline.Runtime.SequenceGapCount, candidate.Runtime.SequenceGapCount, + SvEvidenceChangeSeverity.Warning); + IssueCounter(changes, "Duplicates", baseline.Runtime.DuplicateCount, candidate.Runtime.DuplicateCount, + SvEvidenceChangeSeverity.Warning); + IssueCounter(changes, "Out-of-order", baseline.Runtime.OutOfOrderCount, candidate.Runtime.OutOfOrderCount, + SvEvidenceChangeSeverity.Error); + IssueCounter(changes, "Payload issues", baseline.Runtime.PayloadIssueCount, candidate.Runtime.PayloadIssueCount, + SvEvidenceChangeSeverity.Error); + IssueCounter(changes, "SCL mismatches", baseline.Runtime.SclMismatchCount, candidate.Runtime.SclMismatchCount, + SvEvidenceChangeSeverity.Warning); + + RateChange(changes, "Observed frames/s", baseline.Observation.Facts.ObservedFramesPerSecond, + candidate.Observation.Facts.ObservedFramesPerSecond); + RateChange(changes, "Observed samples/s", baseline.Observation.Facts.ObservedSamplesPerSecond, + candidate.Observation.Facts.ObservedSamplesPerSecond); + WindowChanges(changes, baseline.Observation, candidate.Observation); + BindingChanges(changes, baseline.Observation, candidate.Observation); + ProfileChanges(changes, baseline.Observation.ProfileDetection, candidate.Observation.ProfileDetection); + ConfigurationChanges(changes, baseline.Observation.ConfigurationComparison, + candidate.Observation.ConfigurationComparison); + FactChanges(changes, baseline.Observation.Facts, candidate.Observation.Facts); + DiagnosticChanges(changes, + baseline.Diagnostics.Concat(baseline.Observation.Diagnostics), + candidate.Diagnostics.Concat(candidate.Observation.Diagnostics)); + + var logicalKey = LogicalKey(candidate); + return new SvSubscriberStreamComparison + { + ComparisonKey = $"PAIR|{baseline.Key}|{candidate.Key}", + LogicalStreamKey = logicalKey, + Kind = changes.Count == 0 ? SvEvidenceChangeKind.Unchanged : SvEvidenceChangeKind.Changed, + Severity = changes.Select(change => change.Severity) + .DefaultIfEmpty(SvEvidenceChangeSeverity.Info).Max(), + BaselineStreamKey = baseline.Key, + CandidateStreamKey = candidate.Key, + Identity = candidate.Identity, + Changes = changes + }; + } + + private static void WindowChanges( + ICollection changes, + SvSubscriberObservationEvidence baseline, + SvSubscriberObservationEvidence candidate) + { + if (baseline.WindowSamples != candidate.WindowSamples) + { + var severity = baseline.WindowSamples > 0 && candidate.WindowSamples < baseline.WindowSamples / 2 + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Change(changes, "Observation window", "Samples", severity, + baseline.WindowSamples.ToString(CultureInfo.InvariantCulture), + candidate.WindowSamples.ToString(CultureInfo.InvariantCulture), + severity == SvEvidenceChangeSeverity.Warning + ? "Candidate observation window contains materially fewer samples." + : "Observation-window sample count changed."); + } + + if (!WithinPercent(baseline.WindowDurationSeconds, candidate.WindowDurationSeconds, 0.01)) + { + Change(changes, "Observation window", "Duration", SvEvidenceChangeSeverity.Info, + Number(baseline.WindowDurationSeconds), Number(candidate.WindowDurationSeconds), + "Observation-window duration changed."); + } + + TextChange(changes, "Observation window", "Input kinds", + string.Join(", ", baseline.InputKinds), string.Join(", ", candidate.InputKinds), + SvEvidenceChangeSeverity.Info, "Observation input provenance changed."); + } + + private static void BindingChanges( + ICollection changes, + SvSubscriberObservationEvidence baseline, + SvSubscriberObservationEvidence candidate) + { + if (baseline.IsBoundToScl != candidate.IsBoundToScl) + { + var severity = baseline.IsBoundToScl && !candidate.IsBoundToScl + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Change(changes, "SCL", "Binding", severity, + baseline.IsBoundToScl ? "bound" : "not bound", + candidate.IsBoundToScl ? "bound" : "not bound", + severity == SvEvidenceChangeSeverity.Warning + ? "Candidate stream lost its SCL binding." + : "Candidate stream gained an SCL binding."); + } + + TextChange(changes, "SCL", "Control block", baseline.ControlBlockReference, + candidate.ControlBlockReference, SvEvidenceChangeSeverity.Info, + "SCL control-block reference changed."); + } + + private static void ProfileChanges( + ICollection changes, + SvProfileDetectionResult? baseline, + SvProfileDetectionResult? candidate) + { + TextChange(changes, "Profile", "Profile ID", baseline?.Profile.Id, candidate?.Profile.Id, + SvEvidenceChangeSeverity.Warning, "Detected profile changed."); + + var baselineConfidence = baseline?.Confidence ?? SvProfileConfidence.Unknown; + var candidateConfidence = candidate?.Confidence ?? SvProfileConfidence.Unknown; + if (baselineConfidence == candidateConfidence) + return; + + var severity = candidateConfidence == SvProfileConfidence.Conflict + ? SvEvidenceChangeSeverity.Error + : ConfidenceRank(candidateConfidence) < ConfidenceRank(baselineConfidence) + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Change(changes, "Profile", "Confidence", severity, + baselineConfidence.ToString(), candidateConfidence.ToString(), + severity == SvEvidenceChangeSeverity.Error + ? "Candidate profile classification conflicts with observed evidence." + : severity == SvEvidenceChangeSeverity.Warning + ? "Candidate profile confidence decreased." + : "Candidate profile confidence improved."); + } + + private static void ConfigurationChanges( + ICollection changes, + SvConfigurationComparisonResult? baseline, + SvConfigurationComparisonResult? candidate) + { + var baselineSummary = baseline?.Summary ?? "Not configured"; + var candidateSummary = candidate?.Summary ?? "Not configured"; + if (string.Equals(baselineSummary, candidateSummary, StringComparison.Ordinal) && + baseline?.Mode == candidate?.Mode) + return; + + var blockingIntroduced = candidate?.HasBlockingErrors == true && baseline?.HasBlockingErrors != true; + var warningsIncreased = (candidate?.WarningCount ?? 0) > (baseline?.WarningCount ?? 0); + var severity = blockingIntroduced + ? SvEvidenceChangeSeverity.Error + : warningsIncreased || (baseline is not null && candidate is null) + ? SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Change(changes, "Configuration", "Comparison", severity, baselineSummary, candidateSummary, + blockingIntroduced + ? "Candidate introduced blocking configuration errors." + : severity == SvEvidenceChangeSeverity.Warning + ? "Candidate configuration evidence regressed." + : "Configuration comparison result changed."); + } + + private static void FactChanges( + ICollection changes, + SvObservedStreamFacts baseline, + SvObservedStreamFacts candidate) + { + IntChange(changes, "Observed facts", "Payload bytes per ASDU", baseline.PayloadBytesPerAsdu, + candidate.PayloadBytesPerAsdu, SvEvidenceChangeSeverity.Warning, "Observed payload length changed."); + IntChange(changes, "Observed facts", "Counter wrap", baseline.ObservedCounterWrap, + candidate.ObservedCounterWrap, SvEvidenceChangeSeverity.Warning, "Observed sample-counter wrap changed."); + DoubleChange(changes, "Observed facts", "Nominal frequency", baseline.NominalFrequencyHz, + candidate.NominalFrequencyHz, SvEvidenceChangeSeverity.Warning, "Nominal-frequency context changed."); + TextChange(changes, "Observed facts", "Dataset signature", Signature(baseline.DataSetSignature), + Signature(candidate.DataSetSignature), SvEvidenceChangeSeverity.Error, + "Observed dataset element order or types changed."); + + foreach (var key in baseline.Provenance.Keys.Concat(candidate.Provenance.Keys) + .Distinct(StringComparer.Ordinal)) + { + var baselineSource = baseline.Provenance.TryGetValue(key, out var b) ? b : SvFactSource.Unknown; + var candidateSource = candidate.Provenance.TryGetValue(key, out var c) ? c : SvFactSource.Unknown; + if (baselineSource != candidateSource) + { + Change(changes, "Provenance", key, SvEvidenceChangeSeverity.Info, + baselineSource.ToString(), candidateSource.ToString(), "Fact provenance changed."); + } + } + } + + private static void DiagnosticChanges( + ICollection changes, + IEnumerable baseline, + IEnumerable candidate) + { + var baselineSet = baseline.Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.Ordinal); + var candidateSet = candidate.Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var value in candidateSet.Except(baselineSet, StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal)) + { + Change(changes, "Diagnostics", "Added", SvEvidenceChangeSeverity.Warning, + "-", value, "Candidate introduced a diagnostic."); + } + + foreach (var value in baselineSet.Except(candidateSet, StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal)) + { + Change(changes, "Diagnostics", "Resolved", SvEvidenceChangeSeverity.Info, + value, "-", "A baseline diagnostic is no longer present."); + } + } + + private static void IssueCounter( + ICollection changes, + string field, + int baseline, + int candidate, + SvEvidenceChangeSeverity regressionSeverity) + { + if (baseline == candidate) + return; + var severity = candidate > baseline ? regressionSeverity : SvEvidenceChangeSeverity.Info; + Change(changes, "Runtime integrity", field, severity, + baseline.ToString(CultureInfo.InvariantCulture), candidate.ToString(CultureInfo.InvariantCulture), + candidate > baseline ? $"Candidate {field.ToLowerInvariant()} increased." : $"Candidate {field.ToLowerInvariant()} decreased."); + } + + private static void RateChange( + ICollection changes, + string field, + double? baseline, + double? candidate) + { + if (!baseline.HasValue && !candidate.HasValue) + return; + if (!baseline.HasValue || !candidate.HasValue) + { + Change(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning, + Number(baseline), Number(candidate), "Observed rate availability changed."); + return; + } + if (WithinPercent(baseline.Value, candidate.Value, RateTolerancePercent)) + return; + + Change(changes, "Observed rate", field, SvEvidenceChangeSeverity.Warning, + Number(baseline), Number(candidate), + $"Observed rate changed by more than {RateTolerancePercent:0.###}%."); + } + + private static void HealthChange( + ICollection changes, + string category, + string? baseline, + string? candidate) + { + if (string.Equals(baseline, candidate, StringComparison.OrdinalIgnoreCase)) + return; + var candidateRank = HealthRank(candidate); + var severity = candidateRank > HealthRank(baseline) + ? candidateRank >= 2 ? SvEvidenceChangeSeverity.Error : SvEvidenceChangeSeverity.Warning + : SvEvidenceChangeSeverity.Info; + Change(changes, category, "Health", severity, Text(baseline), Text(candidate), + severity == SvEvidenceChangeSeverity.Info ? "Health improved or changed without regression." : "Health regressed."); + } + + private static void TextChange( + ICollection changes, + string category, + string field, + string? baseline, + string? candidate, + SvEvidenceChangeSeverity severity, + string message) + { + if (!string.Equals(baseline ?? string.Empty, candidate ?? string.Empty, StringComparison.Ordinal)) + Change(changes, category, field, severity, Text(baseline), Text(candidate), message); + } + + private static void IntChange( + ICollection changes, + string category, + string field, + int? baseline, + int? candidate, + SvEvidenceChangeSeverity severity, + string message) + { + if (baseline != candidate) + Change(changes, category, field, severity, Value(baseline), Value(candidate), message); + } + + private static void UIntChange( + ICollection changes, + string category, + string field, + uint? baseline, + uint? candidate, + SvEvidenceChangeSeverity severity, + string message) + { + if (baseline != candidate) + Change(changes, category, field, severity, Value(baseline), Value(candidate), message); + } + + private static void UShortChange( + ICollection changes, + string category, + string field, + ushort? baseline, + ushort? candidate, + SvEvidenceChangeSeverity severity, + string message) + { + if (baseline != candidate) + Change(changes, category, field, severity, Value(baseline), Value(candidate), message); + } + + private static void DoubleChange( + ICollection changes, + string category, + string field, + double? baseline, + double? candidate, + SvEvidenceChangeSeverity severity, + string message) + { + if (baseline != candidate) + Change(changes, category, field, severity, Number(baseline), Number(candidate), message); + } + + private static void Change( + ICollection changes, + string category, + string field, + SvEvidenceChangeSeverity severity, + string baseline, + string candidate, + string message) + => changes.Add(new SvEvidenceFieldChange + { + Category = category, + Field = field, + Severity = severity, + Baseline = baseline, + Candidate = candidate, + Message = message + }); + + private static SvSubscriberStreamComparison Added(SvSubscriberStreamEvidence stream) + { + var logicalKey = LogicalKey(stream); + return new SvSubscriberStreamComparison + { + ComparisonKey = $"ADD|{stream.Key}", + LogicalStreamKey = logicalKey, + Kind = SvEvidenceChangeKind.Added, + Severity = SvEvidenceChangeSeverity.Info, + CandidateStreamKey = stream.Key, + Identity = stream.Identity, + Changes = + [ + new SvEvidenceFieldChange + { + Category = "Stream", + Field = "Presence", + Severity = SvEvidenceChangeSeverity.Info, + Baseline = "absent", + Candidate = "present", + Message = "Candidate contains a new logical stream." + } + ] + }; + } + + private static SvSubscriberStreamComparison Removed(SvSubscriberStreamEvidence stream) + { + var logicalKey = LogicalKey(stream); + return new SvSubscriberStreamComparison + { + ComparisonKey = $"REMOVE|{stream.Key}", + LogicalStreamKey = logicalKey, + Kind = SvEvidenceChangeKind.Removed, + Severity = SvEvidenceChangeSeverity.Error, + BaselineStreamKey = stream.Key, + Identity = stream.Identity, + Changes = + [ + new SvEvidenceFieldChange + { + Category = "Stream", + Field = "Presence", + Severity = SvEvidenceChangeSeverity.Error, + Baseline = "present", + Candidate = "absent", + Message = "Candidate no longer contains this logical stream." + } + ] + }; + } + + private static SvEvidenceReportReference Reference(SvSubscriberEvidenceReport report) + => new() + { + SchemaVersion = report.SchemaVersion, + GeneratedAt = report.GeneratedAt, + Product = report.Software.Product, + Version = report.Software.Version, + Commit = report.Software.Commit, + CaptureSource = report.Capture.Source, + Health = report.Summary.Health, + StreamCount = report.Streams.Count + }; + + private static string LogicalKey(SvSubscriberStreamEvidence stream) + { + var identity = stream.Identity; + var vlan = identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "-"; + return $"SV|{identity.AppId:X4}|{NormalizeMac(identity.DestinationMac)}|{vlan}|" + + $"{NormalizeId(identity.SvId)}|{NormalizeId(identity.DataSetReference)}"; + } + + private static string NormalizeMac(string? value) + => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); + + private static string NormalizeId(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); + + private static int HealthRank(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant() switch + { + "GOOD" => 0, + "IDLE" => 0, + "LISTENING" => 0, + "WARN" => 1, + "BAD" => 2, + "ERROR" => 3, + _ => 1 + }; + + private static int ConfidenceRank(SvProfileConfidence value) + => value switch + { + SvProfileConfidence.Confirmed => 4, + SvProfileConfidence.Likely => 3, + SvProfileConfidence.Possible => 2, + SvProfileConfidence.Unknown => 1, + SvProfileConfidence.Conflict => 0, + _ => 0 + }; + + private static bool WithinPercent(double baseline, double candidate, double tolerancePercent) + { + if (baseline == 0) + return candidate == 0; + return Math.Abs(candidate - baseline) / Math.Abs(baseline) * 100 <= tolerancePercent; + } + + private static string Signature(IReadOnlyList signature) + => signature.Count == 0 + ? string.Empty + : string.Join(",", signature.Select(item => + $"{item.NormalizedBType}|{item.NormalizedCdc}|{item.IsQuality}|{item.IsTimestamp}")); + + private static string Number(double? value) + => value.HasValue ? Number(value.Value) : "unknown"; + + private static string Number(double value) + => value.ToString("0.###", CultureInfo.InvariantCulture); + + private static string Value(T? value) where T : struct + => value.HasValue ? Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? "unknown" : "unknown"; + + private static string Text(string? value) + => string.IsNullOrWhiteSpace(value) ? "unknown" : value; +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs new file mode 100644 index 0000000..7d75820 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparisonSerializer.cs @@ -0,0 +1,177 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Reporting; + +public static class SvSubscriberEvidenceComparisonSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + public static string ToJson(SvSubscriberEvidenceComparison comparison) + { + ArgumentNullException.ThrowIfNull(comparison); + comparison.Validate(); + return JsonSerializer.Serialize(comparison, JsonOptions); + } + + public static SvSubscriberEvidenceComparison FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new ArgumentException("SV comparison JSON cannot be empty.", nameof(json)); + + var comparison = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("SV comparison JSON did not contain a comparison document."); + comparison.Validate(); + return comparison; + } + + public static string ToMarkdown(SvSubscriberEvidenceComparison comparison) + { + ArgumentNullException.ThrowIfNull(comparison); + comparison.Validate(); + + var builder = new StringBuilder(); + builder.AppendLine("# ARSVIN Subscriber Evidence Comparison"); + builder.AppendLine(); + builder.AppendLine("> Baseline-versus-candidate engineering evidence. Warnings and errors identify regressions for review; this is not a formal IEC 61850 conformance certificate."); + builder.AppendLine(); + builder.AppendLine("## Comparison metadata"); + builder.AppendLine(); + KeyValueTable(builder, + [ + ("Schema", comparison.SchemaVersion), + ("Generated", Timestamp(comparison.GeneratedAt)), + ("Baseline generated", Timestamp(comparison.Baseline.GeneratedAt)), + ("Baseline version", comparison.Baseline.Version), + ("Baseline commit", comparison.Baseline.Commit), + ("Baseline capture", comparison.Baseline.CaptureSource), + ("Candidate generated", Timestamp(comparison.Candidate.GeneratedAt)), + ("Candidate version", comparison.Candidate.Version), + ("Candidate commit", comparison.Candidate.Commit), + ("Candidate capture", comparison.Candidate.CaptureSource) + ]); + + builder.AppendLine("## Summary"); + builder.AppendLine(); + KeyValueTable(builder, + [ + ("Baseline streams", comparison.Summary.BaselineStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Candidate streams", comparison.Summary.CandidateStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Added", comparison.Summary.AddedStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Removed", comparison.Summary.RemovedStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Changed", comparison.Summary.ChangedStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Unchanged", comparison.Summary.UnchangedStreamCount.ToString(CultureInfo.InvariantCulture)), + ("Info changes", comparison.Summary.InfoChangeCount.ToString(CultureInfo.InvariantCulture)), + ("Warnings", comparison.Summary.WarningChangeCount.ToString(CultureInfo.InvariantCulture)), + ("Errors", comparison.Summary.ErrorChangeCount.ToString(CultureInfo.InvariantCulture)), + ("Regression status", comparison.Summary.HasRegressions ? "REVIEW REQUIRED" : "NO REGRESSION DETECTED") + ]); + + Changes(builder, "Report-level changes", comparison.ReportChanges); + + builder.AppendLine("## Stream comparison"); + builder.AppendLine(); + builder.AppendLine("| Kind | Severity | APPID | svID | Dataset | Changes |"); + builder.AppendLine("|---|---|---:|---|---|---:|"); + foreach (var stream in comparison.Streams) + { + builder.Append("| ").Append(Cell(stream.Kind.ToString())) + .Append(" | ").Append(Cell(stream.Severity.ToString())) + .Append(" | 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture)) + .Append(" | ").Append(Cell(stream.Identity.SvId)) + .Append(" | ").Append(Cell(stream.Identity.DataSetReference)) + .Append(" | ").Append(stream.Changes.Count.ToString(CultureInfo.InvariantCulture)) + .AppendLine(" |"); + } + builder.AppendLine(); + + foreach (var stream in comparison.Streams.Where(stream => stream.Changes.Count > 0)) + { + builder.Append("## 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture)) + .Append(" — ").AppendLine(Heading(stream.Identity.SvId)); + builder.AppendLine(); + KeyValueTable(builder, + [ + ("Kind", stream.Kind.ToString()), + ("Severity", stream.Severity.ToString()), + ("Logical stream key", stream.LogicalStreamKey), + ("Comparison key", stream.ComparisonKey), + ("Baseline stream key", Empty(stream.BaselineStreamKey)), + ("Candidate stream key", Empty(stream.CandidateStreamKey)), + ("Destination MAC", stream.Identity.DestinationMac), + ("VLAN", stream.Identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "untagged"), + ("Dataset", stream.Identity.DataSetReference) + ]); + Changes(builder, "Changes", stream.Changes); + } + + return builder.ToString(); + } + + private static void Changes( + StringBuilder builder, + string title, + IReadOnlyList changes) + { + builder.Append("## ").AppendLine(title); + builder.AppendLine(); + if (changes.Count == 0) + { + builder.AppendLine("- No changes."); + builder.AppendLine(); + return; + } + + builder.AppendLine("| Severity | Category | Field | Baseline | Candidate | Message |"); + builder.AppendLine("|---|---|---|---|---|---|"); + foreach (var change in changes) + { + builder.Append("| ").Append(Cell(change.Severity.ToString())) + .Append(" | ").Append(Cell(change.Category)) + .Append(" | ").Append(Cell(change.Field)) + .Append(" | ").Append(Cell(change.Baseline)) + .Append(" | ").Append(Cell(change.Candidate)) + .Append(" | ").Append(Cell(change.Message)).AppendLine(" |"); + } + builder.AppendLine(); + } + + private static void KeyValueTable( + StringBuilder builder, + IEnumerable<(string Key, string Value)> rows) + { + builder.AppendLine("| Field | Value |"); + builder.AppendLine("|---|---|"); + foreach (var row in rows) + builder.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(Empty(row.Value))).AppendLine(" |"); + builder.AppendLine(); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + private static string Timestamp(DateTimeOffset value) + => value.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture); + + private static string Empty(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value; + + private static string Cell(string? value) + => Empty(value).Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal); + + private static string Heading(string? value) + => Empty(value).Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal); +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs new file mode 100644 index 0000000..ee6bdec --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs @@ -0,0 +1,587 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Reporting; + +public sealed record SvSubscriberEvidenceReport +{ + public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence/v1"; + + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset GeneratedAt { get; init; } + public SvSubscriberSoftwareEvidence Software { get; init; } = new(); + public SvSubscriberCaptureEvidence Capture { get; init; } = new(); + public SvSubscriberSummaryEvidence Summary { get; init; } = new(); + public IReadOnlyList Streams { get; init; } + = Array.Empty(); + + public void Validate() + { + if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal)) + throw new InvalidOperationException($"Unsupported SV report schema '{SchemaVersion}'."); + if (GeneratedAt == default) + throw new InvalidOperationException("SV report requires a generation timestamp."); + if (string.IsNullOrWhiteSpace(Software.Product)) + throw new InvalidOperationException("SV report requires a product name."); + if (Summary.StreamCount != Streams.Count) + throw new InvalidOperationException("SV report summary stream count does not match the stream evidence collection."); + if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.Key))) + throw new InvalidOperationException("Every SV report stream requires a stable key."); + if (Streams.Select(stream => stream.Key).Distinct(StringComparer.Ordinal).Count() != Streams.Count) + throw new InvalidOperationException("SV report stream keys must be unique."); + } +} + +public sealed record SvSubscriberSoftwareEvidence +{ + public string Product { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public string InformationalVersion { get; init; } = string.Empty; + public string Commit { get; init; } = string.Empty; + public string Repository { get; init; } = string.Empty; +} + +public sealed record SvSubscriberCaptureEvidence +{ + public string Source { get; init; } = "Unknown"; + public string SclPath { get; init; } = string.Empty; + public string Adapter { get; init; } = string.Empty; + public string Filter { get; init; } = string.Empty; + public DateTimeOffset? StartedAt { get; init; } + public DateTimeOffset EndedAt { get; init; } + public double DurationSeconds { get; init; } + public long RawFrames { get; init; } + public long SvFrames { get; init; } + public long ParseErrors { get; init; } + public long DroppedByFilter { get; init; } +} + +public sealed record SvSubscriberSummaryEvidence +{ + public string Health { get; init; } = "IDLE"; + public int StreamCount { get; init; } + public int RuntimeIssueCount { get; init; } + public int ConfigurationFindingCount { get; init; } +} + +public sealed record SvSubscriberStreamEvidence +{ + public string Key { get; init; } = string.Empty; + public string Health { get; init; } = "IDLE"; + public string HealthDetail { get; init; } = string.Empty; + public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new(); + public SvSubscriberRuntimeEvidence Runtime { get; init; } = new(); + public SvSubscriberObservationEvidence Observation { get; init; } = new(); + public IReadOnlyList Phasors { get; init; } + = Array.Empty(); + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); +} + +public sealed record SvSubscriberStreamIdentityEvidence +{ + public ushort AppId { get; init; } + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int AsduPerFrame { get; init; } + public ushort? LastSampleCount { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public byte? SampleSynchronization { get; init; } +} + +public sealed record SvSubscriberRuntimeEvidence +{ + public long FrameCount { get; init; } + public long AsduCount { get; init; } + public double ActualFramesPerSecond { get; init; } + public double AverageFrameGapMilliseconds { get; init; } + public double MaximumFrameGapMilliseconds { get; init; } + public int SequenceGapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderCount { get; init; } + public int PayloadIssueCount { get; init; } + public int SclMismatchCount { get; init; } + public bool IsWaveformWindowReady { get; init; } + public string LayoutBinding { get; init; } = string.Empty; + public string QualitySummary { get; init; } = string.Empty; + public string CursorSummary { get; init; } = string.Empty; + public string LastSeen { get; init; } = string.Empty; +} + +public sealed record SvSubscriberObservationEvidence +{ + public IReadOnlyList InputKinds { get; init; } + = Array.Empty(); + public SvObservationInputKind LastInputKind { get; init; } + public bool IsBoundToScl { get; init; } + public string ControlBlockReference { get; init; } = string.Empty; + public int WindowFrames { get; init; } + public int WindowSamples { get; init; } + public double WindowDurationSeconds { get; init; } + public DateTimeOffset? FirstTimestamp { get; init; } + public DateTimeOffset? LastTimestamp { get; init; } + public SvObservedStreamFacts Facts { get; init; } = new(); + public IReadOnlyDictionary FactProvenance { get; init; } + = new Dictionary(StringComparer.Ordinal); + public SvProfileDetectionResult? ProfileDetection { get; init; } + public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; } + public SvConfigurationComparisonResult? ConfigurationComparison { get; init; } + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); +} + +public sealed record SvSubscriberPhasorEvidence +{ + public string Channel { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public double Rms { get; init; } + public double Peak { get; init; } + public double AngleDegrees { get; init; } +} + +public static class SvSubscriberEvidenceReportSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + public static string ToJson(SvSubscriberEvidenceReport report) + { + ArgumentNullException.ThrowIfNull(report); + report.Validate(); + return JsonSerializer.Serialize(report, JsonOptions); + } + + public static SvSubscriberEvidenceReport FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new ArgumentException("SV report JSON cannot be empty.", nameof(json)); + + var report = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("SV report JSON did not contain a report document."); + report.Validate(); + return report; + } + + public static string ToMarkdown(SvSubscriberEvidenceReport report) + { + ArgumentNullException.ThrowIfNull(report); + report.Validate(); + + var builder = new StringBuilder(); + builder.AppendLine("# ARSVIN Subscriber Evidence Report"); + builder.AppendLine(); + builder.AppendLine("> Receiver-side engineering evidence. This document is not a formal IEC 61850 conformance certificate."); + builder.AppendLine(); + builder.AppendLine("## Report metadata"); + builder.AppendLine(); + AppendKeyValueTable(builder, + [ + ("Schema", report.SchemaVersion), + ("Generated", Timestamp(report.GeneratedAt)), + ("Product", report.Software.Product), + ("Version", report.Software.Version), + ("Informational version", report.Software.InformationalVersion), + ("Commit", report.Software.Commit), + ("Repository", report.Software.Repository), + ("Capture source", report.Capture.Source), + ("SCL", Empty(report.Capture.SclPath, "not loaded")), + ("Adapter", Empty(report.Capture.Adapter)), + ("Filter", Empty(report.Capture.Filter, "none")), + ("Capture started", Timestamp(report.Capture.StartedAt)), + ("Capture ended", Timestamp(report.Capture.EndedAt)), + ("Capture duration", Number(report.Capture.DurationSeconds, "0.###") + " s") + ]); + + builder.AppendLine("## Summary"); + builder.AppendLine(); + AppendKeyValueTable(builder, + [ + ("Health", report.Summary.Health), + ("Raw frames", report.Capture.RawFrames.ToString("N0", CultureInfo.InvariantCulture)), + ("SV frames", report.Capture.SvFrames.ToString("N0", CultureInfo.InvariantCulture)), + ("Streams", report.Summary.StreamCount.ToString("N0", CultureInfo.InvariantCulture)), + ("Runtime issues", report.Summary.RuntimeIssueCount.ToString("N0", CultureInfo.InvariantCulture)), + ("Configuration findings", report.Summary.ConfigurationFindingCount.ToString("N0", CultureInfo.InvariantCulture)), + ("Parse errors", report.Capture.ParseErrors.ToString("N0", CultureInfo.InvariantCulture)), + ("Dropped by filter", report.Capture.DroppedByFilter.ToString("N0", CultureInfo.InvariantCulture)) + ]); + + builder.AppendLine("## Streams"); + builder.AppendLine(); + builder.AppendLine("| Health | APPID | svID | Profile | Confidence | SCL match | Window | Input |" ); + builder.AppendLine("|---|---:|---|---|---|---|---:|---|"); + foreach (var stream in report.Streams) + { + var profile = stream.Observation.ProfileDetection; + var comparison = stream.Observation.ConfigurationComparison; + builder.Append("| ").Append(Cell(stream.Health)) + .Append(" | ").Append(Hex(stream.Identity.AppId)) + .Append(" | ").Append(Cell(stream.Identity.SvId)) + .Append(" | ").Append(Cell(profile?.Profile.DisplayName ?? "Unknown")) + .Append(" | ").Append(Cell(profile?.Confidence.ToString() ?? "Unknown")) + .Append(" | ").Append(Cell(comparison?.Summary ?? "Not configured")) + .Append(" | ").Append(stream.Observation.WindowSamples.ToString("N0", CultureInfo.InvariantCulture)) + .Append(" samples | ").Append(Cell(InputText(stream.Observation.InputKinds))) + .AppendLine(" |"); + } + builder.AppendLine(); + + foreach (var stream in report.Streams) + AppendStream(builder, stream); + + return builder.ToString(); + } + + private static void AppendStream(StringBuilder builder, SvSubscriberStreamEvidence stream) + { + builder.Append("## ").Append(Hex(stream.Identity.AppId)).Append(" — ") + .AppendLine(Heading(stream.Identity.SvId)); + builder.AppendLine(); + AppendKeyValueTable(builder, + [ + ("Stream key", stream.Key), + ("Health", stream.Health), + ("Health detail", stream.HealthDetail), + ("Source MAC", stream.Identity.SourceMac), + ("Destination MAC", stream.Identity.DestinationMac), + ("VLAN", stream.Identity.VlanId?.ToString(CultureInfo.InvariantCulture) ?? "untagged"), + ("VLAN priority", Value(stream.Identity.VlanPriority)), + ("Dataset", stream.Identity.DataSetReference), + ("confRev", Value(stream.Identity.ConfigurationRevision)), + ("nofASDU", stream.Identity.AsduPerFrame.ToString(CultureInfo.InvariantCulture)), + ("Last smpCnt", Value(stream.Identity.LastSampleCount)), + ("Declared sample rate", Value(stream.Identity.DeclaredSampleRate)), + ("Declared sample mode", Value(stream.Identity.DeclaredSampleMode)), + ("smpSynch", Value(stream.Identity.SampleSynchronization)), + ("SCL binding", stream.Observation.IsBoundToScl ? Empty(stream.Observation.ControlBlockReference) : "not bound") + ]); + + builder.AppendLine("### Observation window"); + builder.AppendLine(); + AppendKeyValueTable(builder, + [ + ("Input kinds", InputText(stream.Observation.InputKinds)), + ("Last input kind", stream.Observation.LastInputKind.ToString()), + ("First timestamp", Timestamp(stream.Observation.FirstTimestamp)), + ("Last timestamp", Timestamp(stream.Observation.LastTimestamp)), + ("Duration", Number(stream.Observation.WindowDurationSeconds, "0.###") + " s"), + ("Frames", stream.Observation.WindowFrames.ToString("N0", CultureInfo.InvariantCulture)), + ("Samples", stream.Observation.WindowSamples.ToString("N0", CultureInfo.InvariantCulture)), + ("Observed fps", Number(stream.Observation.Facts.ObservedFramesPerSecond, "0.###")), + ("Observed samples/s", Number(stream.Observation.Facts.ObservedSamplesPerSecond, "0.###")), + ("Observed counter wrap", Value(stream.Observation.Facts.ObservedCounterWrap)) + ]); + + builder.AppendLine("### Runtime integrity"); + builder.AppendLine(); + AppendKeyValueTable(builder, + [ + ("Total frames", stream.Runtime.FrameCount.ToString("N0", CultureInfo.InvariantCulture)), + ("Total ASDUs", stream.Runtime.AsduCount.ToString("N0", CultureInfo.InvariantCulture)), + ("Actual fps", Number(stream.Runtime.ActualFramesPerSecond, "0.###")), + ("Average frame gap", Number(stream.Runtime.AverageFrameGapMilliseconds, "0.###") + " ms"), + ("Maximum frame gap", Number(stream.Runtime.MaximumFrameGapMilliseconds, "0.###") + " ms"), + ("Sequence gaps", stream.Runtime.SequenceGapCount.ToString(CultureInfo.InvariantCulture)), + ("Duplicates", stream.Runtime.DuplicateCount.ToString(CultureInfo.InvariantCulture)), + ("Out-of-order", stream.Runtime.OutOfOrderCount.ToString(CultureInfo.InvariantCulture)), + ("Payload issues", stream.Runtime.PayloadIssueCount.ToString(CultureInfo.InvariantCulture)), + ("SCL mismatches", stream.Runtime.SclMismatchCount.ToString(CultureInfo.InvariantCulture)), + ("Waveform window ready", stream.Runtime.IsWaveformWindowReady ? "yes" : "no"), + ("Layout binding", stream.Runtime.LayoutBinding), + ("Quality", stream.Runtime.QualitySummary), + ("Cursor", stream.Runtime.CursorSummary), + ("Last seen", stream.Runtime.LastSeen) + ]); + + AppendObservedFacts(builder, stream.Observation.Facts); + AppendExpectedConfiguration(builder, stream.Observation.ExpectedConfiguration); + AppendConfigurationFindings(builder, stream.Observation.ConfigurationComparison); + AppendProfileEvidence(builder, stream.Observation.ProfileDetection); + AppendPhasors(builder, stream.Phasors); + AppendDiagnostics(builder, stream.Diagnostics, stream.Observation.Diagnostics); + } + + private static void AppendObservedFacts(StringBuilder builder, SvObservedStreamFacts facts) + { + builder.AppendLine("### Observed facts and provenance"); + builder.AppendLine(); + builder.AppendLine("| Fact | Value | Source |"); + builder.AppendLine("|---|---|---|"); + AppendFact(builder, facts, nameof(facts.EtherType), "EtherType", facts.EtherType.HasValue ? Hex(facts.EtherType.Value) : "unknown"); + AppendFact(builder, facts, nameof(facts.AppId), "APPID", facts.AppId.HasValue ? Hex(facts.AppId.Value) : "unknown"); + AppendFact(builder, facts, nameof(facts.DestinationMac), "Destination MAC", facts.DestinationMac); + AppendFact(builder, facts, nameof(facts.VlanId), "VLAN ID", Value(facts.VlanId)); + AppendFact(builder, facts, nameof(facts.VlanPriority), "VLAN priority", Value(facts.VlanPriority)); + AppendFact(builder, facts, nameof(facts.SvId), "svID", facts.SvId); + AppendFact(builder, facts, nameof(facts.DataSetReference), "Dataset reference", facts.DataSetReference); + AppendFact(builder, facts, nameof(facts.ConfigurationRevision), "confRev", Value(facts.ConfigurationRevision)); + AppendFact(builder, facts, nameof(facts.AsduPerFrame), "ASDU per frame", Value(facts.AsduPerFrame)); + AppendFact(builder, facts, nameof(facts.PayloadBytesPerAsdu), "Payload bytes per ASDU", Value(facts.PayloadBytesPerAsdu)); + AppendFact(builder, facts, nameof(facts.DeclaredSampleRate), "Declared sample rate", Value(facts.DeclaredSampleRate)); + AppendFact(builder, facts, nameof(facts.DeclaredSampleMode), "Declared sample mode", Value(facts.DeclaredSampleMode)); + AppendFact(builder, facts, nameof(facts.ObservedFramesPerSecond), "Observed frames/s", Number(facts.ObservedFramesPerSecond, "0.###")); + AppendFact(builder, facts, nameof(facts.ObservedSamplesPerSecond), "Observed samples/s", Number(facts.ObservedSamplesPerSecond, "0.###")); + AppendFact(builder, facts, nameof(facts.ObservedCounterWrap), "Observed counter wrap", Value(facts.ObservedCounterWrap)); + AppendFact(builder, facts, nameof(facts.NominalFrequencyHz), "Nominal frequency", Number(facts.NominalFrequencyHz, "0.###")); + AppendFact(builder, facts, nameof(facts.DataSetSignature), "Dataset signature", Signature(facts.DataSetSignature)); + var transitions = facts.CounterTransitions; + AppendFact(builder, facts, nameof(facts.CounterTransitions), "Counter transitions", + $"sequential {transitions.SequentialCount}, gaps {transitions.GapCount}, duplicates {transitions.DuplicateCount}, out-of-order/reset {transitions.OutOfOrderOrResetCount}, wraps {transitions.ConfirmedWrapCount}"); + builder.AppendLine(); + } + + private static void AppendExpectedConfiguration(StringBuilder builder, SvExpectedStreamConfiguration? expected) + { + builder.AppendLine("### Expected SCL configuration"); + builder.AppendLine(); + if (expected is null) + { + builder.AppendLine("- Not configured."); + builder.AppendLine(); + return; + } + + AppendKeyValueTable(builder, + [ + ("EtherType", expected.EtherType.HasValue ? Hex(expected.EtherType.Value) : "unknown"), + ("APPID", expected.AppId.HasValue ? Hex(expected.AppId.Value) : "unknown"), + ("Destination MAC", expected.DestinationMac), + ("VLAN ID", Value(expected.VlanId)), + ("VLAN priority", Value(expected.VlanPriority)), + ("svID", expected.SvId), + ("Dataset", expected.DataSetReference), + ("confRev", Value(expected.ConfigurationRevision)), + ("ASDU per frame", Value(expected.AsduPerFrame)), + ("Payload bytes per ASDU", Value(expected.PayloadBytesPerAsdu)), + ("Declared sample rate", Value(expected.DeclaredSampleRate)), + ("Declared sample mode", Value(expected.DeclaredSampleMode)), + ("Dataset signature", Signature(expected.DataSetSignature)) + ]); + } + + private static void AppendConfigurationFindings(StringBuilder builder, SvConfigurationComparisonResult? comparison) + { + builder.AppendLine("### Configuration comparison"); + builder.AppendLine(); + if (comparison is null) + { + builder.AppendLine("- Not configured."); + builder.AppendLine(); + return; + } + + builder.Append("- Mode: **").Append(comparison.Mode).AppendLine("**"); + builder.Append("- Result: **").Append(comparison.Summary).AppendLine("**"); + builder.AppendLine(); + if (comparison.Findings.Count == 0) + { + builder.AppendLine("- No differences found."); + builder.AppendLine(); + return; + } + + builder.AppendLine("| Severity | Code | Field | Expected | Observed | Message |"); + builder.AppendLine("|---|---|---|---|---|---|"); + foreach (var finding in comparison.Findings) + { + builder.Append("| ").Append(Cell(finding.Severity.ToString())) + .Append(" | ").Append(Cell(finding.Code)) + .Append(" | ").Append(Cell(finding.Field)) + .Append(" | ").Append(Cell(finding.Expected)) + .Append(" | ").Append(Cell(finding.Observed)) + .Append(" | ").Append(Cell(finding.Message)).AppendLine(" |"); + } + builder.AppendLine(); + } + + private static void AppendProfileEvidence(StringBuilder builder, SvProfileDetectionResult? detection) + { + builder.AppendLine("### Profile detection evidence"); + builder.AppendLine(); + if (detection is null) + { + builder.AppendLine("- No profile result."); + builder.AppendLine(); + return; + } + + AppendKeyValueTable(builder, + [ + ("Profile", detection.Profile.DisplayName), + ("Profile ID", detection.Profile.Id), + ("Family", detection.Profile.Family), + ("Confidence", detection.Confidence.ToString()), + ("Raw confidence", detection.RawConfidence.ToString()), + ("Score", Number(detection.ScorePercent, "0.###") + "%"), + ("Matched weight", detection.MatchedWeight.ToString(CultureInfo.InvariantCulture)), + ("Conflict weight", detection.ConflictWeight.ToString(CultureInfo.InvariantCulture)), + ("Evaluated weight", detection.EvaluatedWeight.ToString(CultureInfo.InvariantCulture)), + ("Evidence status", detection.Profile.EvidenceStatus.ToString()) + ]); + + builder.AppendLine("#### Evidence sources"); + builder.AppendLine(); + builder.AppendLine("| Source | Status | Description |"); + builder.AppendLine("|---|---|---|"); + foreach (var source in detection.Profile.Sources) + { + builder.Append("| ").Append(Cell(source.SourceId)) + .Append(" | ").Append(Cell(source.Status.ToString())) + .Append(" | ").Append(Cell(source.Description)).AppendLine(" |"); + } + builder.AppendLine(); + + builder.AppendLine("#### Match evidence"); + builder.AppendLine(); + if (detection.Evidence.Count == 0) + { + builder.AppendLine("- No evaluated evidence fields."); + builder.AppendLine(); + return; + } + + builder.AppendLine("| Field | Outcome | Weight | Expected | Observed | Message |"); + builder.AppendLine("|---|---|---:|---|---|---|"); + foreach (var evidence in detection.Evidence) + { + builder.Append("| ").Append(Cell(evidence.Field)) + .Append(" | ").Append(Cell(evidence.Outcome.ToString())) + .Append(" | ").Append(evidence.Weight.ToString(CultureInfo.InvariantCulture)) + .Append(" | ").Append(Cell(evidence.Expected)) + .Append(" | ").Append(Cell(evidence.Observed)) + .Append(" | ").Append(Cell(evidence.Message)).AppendLine(" |"); + } + builder.AppendLine(); + } + + private static void AppendPhasors(StringBuilder builder, IReadOnlyList phasors) + { + builder.AppendLine("### Phasors"); + builder.AppendLine(); + if (phasors.Count == 0) + { + builder.AppendLine("- No complete decoded phasor window."); + builder.AppendLine(); + return; + } + + builder.AppendLine("| Channel | Kind | RMS | Peak | Angle |"); + builder.AppendLine("|---|---|---:|---:|---:|"); + foreach (var phasor in phasors) + { + builder.Append("| ").Append(Cell(phasor.Channel)) + .Append(" | ").Append(Cell(phasor.Kind)) + .Append(" | ").Append(Number(phasor.Rms, "0.###")) + .Append(" | ").Append(Number(phasor.Peak, "0.###")) + .Append(" | ").Append(Number(phasor.AngleDegrees, "0.###")).AppendLine("° |"); + } + builder.AppendLine(); + } + + private static void AppendDiagnostics( + StringBuilder builder, + IReadOnlyList runtimeDiagnostics, + IReadOnlyList observationDiagnostics) + { + builder.AppendLine("### Diagnostics"); + builder.AppendLine(); + var diagnostics = runtimeDiagnostics.Concat(observationDiagnostics) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (diagnostics.Length == 0) + { + builder.AppendLine("- No diagnostics."); + builder.AppendLine(); + return; + } + + foreach (var diagnostic in diagnostics) + builder.Append("- ").AppendLine(diagnostic); + builder.AppendLine(); + } + + private static void AppendFact( + StringBuilder builder, + SvObservedStreamFacts facts, + string propertyName, + string label, + string value) + { + var source = facts.Provenance.TryGetValue(propertyName, out var factSource) + ? factSource.ToString() + : SvFactSource.Unknown.ToString(); + builder.Append("| ").Append(Cell(label)) + .Append(" | ").Append(Cell(Empty(value, "unknown"))) + .Append(" | ").Append(Cell(source)).AppendLine(" |"); + } + + private static void AppendKeyValueTable(StringBuilder builder, IEnumerable<(string Key, string Value)> rows) + { + builder.AppendLine("| Field | Value |"); + builder.AppendLine("|---|---|"); + foreach (var row in rows) + builder.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(Empty(row.Value))).AppendLine(" |"); + builder.AppendLine(); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + private static string InputText(IReadOnlyList inputKinds) + => inputKinds.Count == 0 ? "Unknown" : string.Join(", ", inputKinds); + + private static string Signature(IReadOnlyList signature) + => signature.Count == 0 + ? "unknown" + : string.Join(", ", signature.Select(item => + $"{Empty(item.BType, "-")}/{Empty(item.Cdc, "-")}" + + (item.IsQuality ? ":quality" : string.Empty) + + (item.IsTimestamp ? ":timestamp" : string.Empty))); + + private static string Timestamp(DateTimeOffset? value) + => value.HasValue ? Timestamp(value.Value) : "unknown"; + + private static string Timestamp(DateTimeOffset value) + => value.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture); + + private static string Hex(ushort value) + => $"0x{value:X4}"; + + private static string Number(double? value, string format) + => value.HasValue ? Number(value.Value, format) : "unknown"; + + private static string Number(double value, string format) + => value.ToString(format, CultureInfo.InvariantCulture); + + private static string Value(T? value) where T : struct + => value.HasValue ? Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? "unknown" : "unknown"; + + private static string Empty(string? value, string fallback = "-") + => string.IsNullOrWhiteSpace(value) ? fallback : value; + + private static string Cell(string? value) + => Empty(value).Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal); + + private static string Heading(string? value) + => Empty(value, "Unknown stream").Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal); +} diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml index b965643..27d69bb 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml +++ b/src/ARSVIN.Subscriber/MainWindow.xaml @@ -102,6 +102,7 @@