diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index bcdf77a..98dc9b1 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -54,4 +54,23 @@ jobs: run: dotnet build .\source\ARIEC61850.sln -c Release --no-restore - name: Test - run: dotnet test .\source\ARIEC61850.sln -c Release --no-build --verbosity normal + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/test-results -Force | Out-Null + dotnet test .\source\ARIEC61850.sln ` + -c Release ` + --no-build ` + --verbosity normal ` + --logger "trx;LogFileName=ARIEC61850.Tests.trx" ` + --results-directory artifacts/test-results 2>&1 | + Tee-Object -FilePath artifacts/test-results/dotnet-test.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ariec61850-test-diagnostics + path: artifacts/test-results + if-no-files-found: warn + retention-days: 14 diff --git a/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md b/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md new file mode 100644 index 0000000..58e93ef --- /dev/null +++ b/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md @@ -0,0 +1,61 @@ +# Sampled Values core ownership + +## Decision + +`ARIEC61850` is the single source of truth for reusable IEC 61850 protocol, Sampled Values decoding, engineering analysis, measurement-domain conversion, diagnostics, and evidence contracts. + +Derived applications such as ARSVIN Publisher and ArSubsv Subscriber may provide WPF presentation, commands, file dialogs, adapter selection, orchestration, and user-facing formatting. They must not fork or silently modify protocol and measurement behavior in application repositories. + +## Core imported from the ARSVIN application repository + +The following reusable components were present in the embedded `ARSVIN.Engine` copy but absent from `ARIEC61850/main` during the July 2026 ownership audit: + +- evidence-driven engineering scaling; +- profile-neutral `smpCnt` transition tracking; +- timebase resolution without a hidden 50/60 Hz assumption; +- explicit CT/VT ratio and primary/secondary-domain conversion; +- semantic Sampled Values quality decoding; +- versioned stream measurement-context JSON; +- generic ASDU inspection; +- generic `seqOfData` word inspection with preserved trailing bytes. + +These components are vendor-neutral. Manufacturer or product identity must never select a parser, dataset order, scaling rule, quality interpretation, timebase, or health result. + +## Ownership boundary + +### ARIEC61850 + +- Ethernet, VLAN, APPID, BER, APDU, and ASDU codecs; +- Sampled Values frame parsing and building; +- SCL parsing and ordered FCDA/DataSet mapping; +- generic raw payload inspection; +- quality semantics; +- continuity, timebase, scaling, CT/VT, waveform, RMS, phasor, noise-floor, and signal-validity analysis; +- protocol, stream, configuration, and measurement health contracts; +- PCAP and Npcap transport abstractions; +- reusable evidence and comparison models; +- deterministic protocol and engineering tests. + +### Derived applications + +- WPF controls and windows; +- ViewModels and commands; +- selected-stream state and UI refresh policy; +- adapter and file-selection workflows; +- plot rendering and display formatting; +- application settings, branding, and packaging; +- application smoke and presentation tests. + +## Integration contract + +Local application development uses sibling repositories: + +```text +/ +├── ARIEC61850/ +└── arsvin/ +``` + +Application CI must pin and checkout a reviewed ARIEC61850 commit or branch as a sibling. It must not build against a moving, unrecorded engine revision. + +The migration is intentionally staged. Existing embedded source can remain temporarily for comparison, but it must be removed from active project references. New reusable IEC 61850 logic belongs here first. diff --git a/src/AR.Iec61850/Ethernet/MacAddress.cs b/src/AR.Iec61850/Ethernet/MacAddress.cs index cc86b1f..f125e88 100644 --- a/src/AR.Iec61850/Ethernet/MacAddress.cs +++ b/src/AR.Iec61850/Ethernet/MacAddress.cs @@ -51,6 +51,30 @@ public static bool TryParse(string? text, out MacAddress address) public byte[] ToArray() => _bytes?.ToArray() ?? new byte[6]; + /// + /// Compares the six address octets by value. The backing array is an implementation detail + /// and must not make two equivalent MAC addresses compare as different instances. + /// + public bool Equals(MacAddress other) + { + for (var i = 0; i < 6; i++) + { + if (ByteAt(i) != other.ByteAt(i)) + return false; + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + for (var i = 0; i < 6; i++) + hash.Add(ByteAt(i)); + + return hash.ToHashCode(); + } + public void CopyTo(Span destination) { if (destination.Length < 6) @@ -64,4 +88,7 @@ public override string ToString() var bytes = _bytes ?? new byte[6]; return string.Join(":", bytes.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); } + + private byte ByteAt(int index) + => _bytes is null ? (byte)0 : _bytes[index]; } diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs b/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs new file mode 100644 index 0000000..da5ef74 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs @@ -0,0 +1,58 @@ +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Generic, vendor-neutral view of one Sampled Values ASDU. +/// It separates fields observed on the wire from semantic dataset interpretation. +/// +public sealed record SvGenericAsduInspection +{ + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public ushort SampleCount { get; init; } + public uint ConfigurationRevision { get; init; } + public bool HasReferenceTime { get; init; } + public byte SampleSynchronization { get; init; } + public ushort? SampleRate { get; init; } + public ushort? SampleMode { get; init; } + public SvGenericPayloadInspection Payload { get; init; } = new(); + + public string MappingState => string.IsNullOrWhiteSpace(DataSetReference) + ? "Dataset reference not present · semantic mapping unresolved" + : "Dataset reference observed · import or bind SCL to resolve ordered semantics"; + + public string OptionalFieldSummary + { + get + { + var fields = new List(); + if (HasReferenceTime) + fields.Add("refrTm"); + if (SampleRate.HasValue) + fields.Add("smpRate"); + if (SampleMode.HasValue) + fields.Add("smpMod"); + return fields.Count == 0 ? "No optional ASDU fields observed" : string.Join(", ", fields); + } + } +} + +public static class SvGenericAsduInspector +{ + public static SvGenericAsduInspection Inspect(SampledValueAsdu asdu) + { + ArgumentNullException.ThrowIfNull(asdu); + + return new SvGenericAsduInspection + { + SvId = asdu.SvId, + DataSetReference = asdu.DataSetReference, + SampleCount = asdu.SampleCount, + ConfigurationRevision = asdu.ConfigurationRevision, + HasReferenceTime = asdu.ReferenceTime is not null, + SampleSynchronization = asdu.SampleSynchronization, + SampleRate = asdu.SampleRate, + SampleMode = asdu.SampleMode, + Payload = SvGenericPayloadInspector.Inspect(asdu.SamplePayload) + }; + } +} diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs b/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs new file mode 100644 index 0000000..6b201c7 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Describes only the structural position of a 32-bit word in seqOfData. +/// These roles do not assert channel semantics, engineering units, or IEC 61850 quality meaning. +/// +public enum SvGenericPayloadWordRole +{ + StandaloneWord, + FirstWordInEightByteGroup, + SecondWordInEightByteGroup +} + +/// +/// One four-byte big-endian word from an SV sample payload, exposed through multiple numeric views. +/// The views are representations of the same bytes and are not automatic semantic interpretations. +/// +public sealed record SvGenericPayloadWord +{ + public int Index { get; init; } + public int ByteOffset { get; init; } + public SvGenericPayloadWordRole StructuralRole { get; init; } + public byte[] RawBytes { get; init; } = []; + public string Hex { get; init; } = string.Empty; + public int SignedInt32 { get; init; } + public uint UnsignedInt32 { get; init; } + public float Float32 { get; init; } + public bool IsFiniteFloat32 => float.IsFinite(Float32); + + public string GenericLabel => $"Word {Index + 1}"; + public string OffsetLabel => $"+0x{ByteOffset:X2}"; +} + +/// +/// Vendor-neutral structural inspection of one ASDU seqOfData payload. +/// It deliberately preserves unknown semantics instead of inventing current/voltage channels. +/// +public sealed record SvGenericPayloadInspection +{ + public int PayloadLength { get; init; } + public int CompleteWordCount { get; init; } + public int TrailingByteCount { get; init; } + public bool IsFourByteAligned { get; init; } + public bool HasEightByteGroupShape { get; init; } + public IReadOnlyList Words { get; init; } + = Array.Empty(); + public byte[] TrailingBytes { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); + + public string Summary + { + get + { + if (PayloadLength == 0) + return "Empty seqOfData payload"; + + var grouping = HasEightByteGroupShape + ? $"{PayloadLength / 8} structural 8-byte group(s)" + : $"{CompleteWordCount} complete 32-bit word(s)"; + return TrailingByteCount == 0 + ? $"Raw generic inspection · {PayloadLength} bytes · {grouping}" + : $"Raw generic inspection · {PayloadLength} bytes · {grouping} · {TrailingByteCount} trailing byte(s)"; + } + } +} + +/// +/// Generic seqOfData inspector used when no trusted dataset layout is available. +/// It never labels words as phase channels, voltage, current, quality, primary, secondary, A, or V. +/// +public static class SvGenericPayloadInspector +{ + private const int WordBytes = 4; + private const int StructuralGroupBytes = 8; + + public static SvGenericPayloadInspection Inspect(ReadOnlyMemory payload) + { + var span = payload.Span; + var completeWordCount = span.Length / WordBytes; + var trailingByteCount = span.Length % WordBytes; + var hasEightByteGroupShape = span.Length >= StructuralGroupBytes && span.Length % StructuralGroupBytes == 0; + var words = new SvGenericPayloadWord[completeWordCount]; + + for (var index = 0; index < completeWordCount; index++) + { + var byteOffset = index * WordBytes; + var wordBytes = span.Slice(byteOffset, WordBytes); + var unsigned = BinaryPrimitives.ReadUInt32BigEndian(wordBytes); + words[index] = new SvGenericPayloadWord + { + Index = index, + ByteOffset = byteOffset, + StructuralRole = ResolveRole(index, hasEightByteGroupShape), + RawBytes = wordBytes.ToArray(), + Hex = Convert.ToHexString(wordBytes), + SignedInt32 = unchecked((int)unsigned), + UnsignedInt32 = unsigned, + Float32 = BitConverter.Int32BitsToSingle(unchecked((int)unsigned)) + }; + } + + var trailingBytes = trailingByteCount == 0 + ? Array.Empty() + : span[^trailingByteCount..].ToArray(); + var diagnostics = BuildDiagnostics(span.Length, completeWordCount, trailingByteCount, hasEightByteGroupShape); + + return new SvGenericPayloadInspection + { + PayloadLength = span.Length, + CompleteWordCount = completeWordCount, + TrailingByteCount = trailingByteCount, + IsFourByteAligned = trailingByteCount == 0, + HasEightByteGroupShape = hasEightByteGroupShape, + Words = words, + TrailingBytes = trailingBytes, + Diagnostics = diagnostics + }; + } + + private static SvGenericPayloadWordRole ResolveRole(int wordIndex, bool hasEightByteGroupShape) + { + if (!hasEightByteGroupShape) + return SvGenericPayloadWordRole.StandaloneWord; + return wordIndex % 2 == 0 + ? SvGenericPayloadWordRole.FirstWordInEightByteGroup + : SvGenericPayloadWordRole.SecondWordInEightByteGroup; + } + + private static IReadOnlyList BuildDiagnostics( + int payloadLength, + int completeWordCount, + int trailingByteCount, + bool hasEightByteGroupShape) + { + var diagnostics = new List(); + if (payloadLength == 0) + { + diagnostics.Add("seqOfData is empty."); + return diagnostics; + } + + diagnostics.Add( + $"Generic inspection exposed {completeWordCount} complete big-endian 32-bit word(s) without assigning channel names or engineering units."); + + if (hasEightByteGroupShape) + { + diagnostics.Add( + "The payload has an 8-byte grouping shape. This is structural evidence only; the second word is not treated as IEC 61850 quality until SCL or an explicit standard layout resolves it."); + } + + if (trailingByteCount > 0) + { + diagnostics.Add( + $"The payload contains {trailingByteCount} trailing byte(s) after the last complete 32-bit word; those bytes are preserved verbatim."); + } + + return diagnostics; + } +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs new file mode 100644 index 0000000..ce44038 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -0,0 +1,181 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// Describes how a decoded Sampled Values number is converted into an engineering value. +/// The source and confidence are deliberately explicit so unknown traffic is never labelled A or V by guesswork. +/// +public sealed record SvEngineeringScale +{ + public static SvEngineeringScale RawOnly(string reason) => new() + { + Multiplier = 1.0, + Unit = "count", + Source = SvEngineeringScaleSource.RawOnly, + Confidence = SvEngineeringScaleConfidence.Unknown, + Reason = reason + }; + + public double Multiplier { get; init; } = 1.0; + public double Offset { get; init; } + public string Unit { get; init; } = "count"; + public SvEngineeringScaleSource Source { get; init; } = SvEngineeringScaleSource.RawOnly; + public SvEngineeringScaleConfidence Confidence { get; init; } = SvEngineeringScaleConfidence.Unknown; + public string Reason { get; init; } = string.Empty; + public bool HasEngineeringUnit => Source != SvEngineeringScaleSource.RawOnly; + + public double Apply(double rawValue) => (rawValue * Multiplier) + Offset; +} + +public enum SvEngineeringScaleSource +{ + RawOnly, + + /// + /// Retained for evidence-document compatibility. Generic receive-side analysis no longer activates + /// engineering scaling from packet shape or observed rate alone. + /// + Legacy92LeStyleStructuralInference, + + SclBackedLegacy92LeStyle, + ManualOverride +} + +public enum SvEngineeringScaleConfidence +{ + Unknown, + Inferred, + SclBacked, + DeviceValidated +} + +/// +/// Evidence supplied to the scale resolver. This is intentionally vendor-neutral. +/// Product identity may be retained in an evidence report, but it is not a scaling rule. +/// +public sealed record SvEngineeringScaleEvidence +{ + public string Channel { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public bool IsSclBound { get; init; } + public bool IsFixedFourCurrentFourVoltageLayout { get; init; } + public int AnalogChannelCount { get; init; } + public int PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } +} + +/// +/// Resolves conservative engineering scaling for an explicitly SCL-bound installed-base +/// 4-current/4-voltage protection layout. Packet shape, rate, product name, APPID, MAC address, +/// svID, and amplitude are never sufficient by themselves. +/// +public static class SvEngineeringScaleResolver +{ + private const double CurrentAmperesPerCount = 0.001; + private const double VoltageVoltsPerCount = 0.01; + private const double RateToleranceFraction = 0.02; + + public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + var domain = ResolveDomain(evidence.Channel, evidence.Kind); + if (domain == SvMeasurementDomain.Unknown) + return SvEngineeringScale.RawOnly("The SCL-derived channel semantics do not prove a current or voltage domain."); + + if (!evidence.IsSclBound) + { + return SvEngineeringScale.RawOnly( + "No SCL dataset mapping is bound. Generic packet inspection preserves raw counts and does not infer engineering units."); + } + + var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && + evidence.AnalogChannelCount == 8 && + evidence.PayloadBytesPerAsdu == 64; + if (!fixedLayout) + { + return SvEngineeringScale.RawOnly( + "The SCL-mapped payload is not the fixed 4-current/4-voltage value-quality layout required by this scaling rule."); + } + + if (!HasProtectionRateEvidence(evidence)) + { + return SvEngineeringScale.RawOnly( + "The fixed SCL layout is visible, but declared or observed protection-rate evidence is insufficient for the installed-base scaling rule."); + } + + var reason = + "SCL dataset mapping, fixed 4-current/4-voltage structure, and protection-rate evidence support the installed-base 9-2LE-style scale."; + + return domain switch + { + SvMeasurementDomain.Current => new SvEngineeringScale + { + Multiplier = CurrentAmperesPerCount, + Unit = "A", + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, + Reason = reason + }, + SvMeasurementDomain.Voltage => new SvEngineeringScale + { + Multiplier = VoltageVoltsPerCount, + Unit = "V", + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, + Reason = reason + }, + _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") + }; + } + + public static SvMeasurementDomain ResolveDomain(string channel, string kind) + { + var normalizedKind = kind?.Trim() ?? string.Empty; + if (normalizedKind.Contains("voltage", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedKind.Contains("current", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + var normalizedChannel = channel?.Trim() ?? string.Empty; + if (normalizedChannel.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedChannel.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + return SvMeasurementDomain.Unknown; + } + + private static bool HasProtectionRateEvidence(SvEngineeringScaleEvidence evidence) + { + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate == 80) + return true; + + if (evidence.DeclaredSampleMode == 1 && + IsNearOneOf(evidence.DeclaredSampleRate, 4_000, 4_800)) + return true; + + return IsNearOneOf(evidence.ObservedSamplesPerSecond, 4_000, 4_800); + } + + private static bool IsNearOneOf(double? value, params double[] candidates) + { + if (!value.HasValue || value.Value <= 0) + return false; + + return candidates.Any(candidate => + Math.Abs(value.Value - candidate) <= candidate * RateToleranceFraction); + } +} + +public enum SvMeasurementDomain +{ + Unknown, + Current, + Voltage +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs new file mode 100644 index 0000000..f9b85a3 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// User-supplied measurement-domain context for one logical SV stream. +/// The stream key is the evidence identity used by a consumer, not a vendor name. +/// +public sealed record SvStreamMeasurementContext +{ + public string StreamKey { get; init; } = string.Empty; + public string SvId { get; init; } = string.Empty; + public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementValueDomain DisplayDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementRatio? CurrentRatio { get; init; } + public SvMeasurementRatio? VoltageRatio { get; init; } + public string Notes { get; init; } = string.Empty; + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + + public SvMeasurementRatio? ResolveRatio(string kindOrChannel) + { + var text = kindOrChannel?.Trim() ?? string.Empty; + if (text.Contains("voltage", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + text.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return VoltageRatio; + + if (text.Contains("current", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + text.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return CurrentRatio; + + return null; + } + + public string Summary + { + get + { + var current = CurrentRatio?.IsValid == true + ? $"I {CurrentRatio.PrimaryNominal:0.###}/{CurrentRatio.SecondaryNominal:0.###} {CurrentRatio.Unit}" + : "I ratio unset"; + var voltage = VoltageRatio?.IsValid == true + ? $"V {VoltageRatio.PrimaryNominal:0.###}/{VoltageRatio.SecondaryNominal:0.###} {VoltageRatio.Unit}" + : "V ratio unset"; + return $"wire {WireDomain} → display {DisplayDomain} · {current} · {voltage}"; + } + } + + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(StreamKey)) + errors.Add("StreamKey is required."); + if (WireDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("WireDomain must be PrimaryEngineering or SecondaryEquivalent."); + if (DisplayDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("DisplayDomain must be PrimaryEngineering or SecondaryEquivalent."); + + ValidateRatio(CurrentRatio, "current", "A", errors); + ValidateRatio(VoltageRatio, "voltage", "V", errors); + return errors; + } + + private static void ValidateRatio( + SvMeasurementRatio? ratio, + string label, + string expectedUnit, + ICollection errors) + { + if (ratio is null) + return; + if (!ratio.IsValid) + errors.Add($"The {label} ratio requires positive finite primary and secondary nominal values."); + if (!string.Equals(ratio.Unit, expectedUnit, StringComparison.OrdinalIgnoreCase)) + errors.Add($"The {label} ratio unit must be {expectedUnit}."); + if (ratio.Source == SvRatioSource.Unknown) + errors.Add($"The {label} ratio source must be explicit."); + } +} + +public sealed record SvMeasurementContextDocument +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset ExportedAt { get; init; } = DateTimeOffset.UtcNow; + public IReadOnlyList Streams { get; init; } + = Array.Empty(); + + public IReadOnlyList Validate() + { + var errors = new List(); + if (SchemaVersion != CurrentSchemaVersion) + errors.Add($"Unsupported measurement-context schema version {SchemaVersion}; expected {CurrentSchemaVersion}."); + + var duplicateKeys = Streams + .Where(item => !string.IsNullOrWhiteSpace(item.StreamKey)) + .GroupBy(item => item.StreamKey, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + errors.AddRange(duplicateKeys.Select(key => $"Duplicate measurement context for stream key '{key}'.")); + + foreach (var stream in Streams) + { + var label = string.IsNullOrWhiteSpace(stream.SvId) ? stream.StreamKey : stream.SvId; + errors.AddRange(stream.Validate().Select(error => $"{label}: {error}")); + } + return errors; + } +} + +public static class SvMeasurementContextSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static string ToJson(SvMeasurementContextDocument document) + { + ArgumentNullException.ThrowIfNull(document); + EnsureValid(document); + return JsonSerializer.Serialize(document, Options); + } + + public static SvMeasurementContextDocument FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("Measurement-context JSON is empty."); + + SvMeasurementContextDocument document; + try + { + document = JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("Measurement-context JSON did not contain a document."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Measurement-context JSON is invalid: {ex.Message}", ex); + } + + EnsureValid(document); + return document; + } + + private static void EnsureValid(SvMeasurementContextDocument document) + { + var errors = document.Validate(); + if (errors.Count > 0) + throw new InvalidDataException("Measurement-context validation failed: " + string.Join(" ", errors)); + } +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs new file mode 100644 index 0000000..160951d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs @@ -0,0 +1,135 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvMeasurementValueDomain +{ + Unknown, + RawCount, + PrimaryEngineering, + SecondaryEquivalent +} + +public enum SvRatioSource +{ + Unknown, + Scl, + DeviceConfiguration, + Manual, + DeviceValidated +} + +/// +/// Explicit instrument-transformer context. It is never inferred from vendor identity or nominal amplitude. +/// +public sealed record SvMeasurementRatio +{ + public double PrimaryNominal { get; init; } + public double SecondaryNominal { get; init; } + public string Unit { get; init; } = string.Empty; + public SvRatioSource Source { get; init; } = SvRatioSource.Unknown; + public string Reference { get; init; } = string.Empty; + + public bool IsValid => double.IsFinite(PrimaryNominal) && + double.IsFinite(SecondaryNominal) && + PrimaryNominal > 0 && + SecondaryNominal > 0; + + public double Ratio => IsValid ? PrimaryNominal / SecondaryNominal : double.NaN; + + public double PrimaryToSecondary(double primaryValue) + { + EnsureValid(); + return primaryValue * SecondaryNominal / PrimaryNominal; + } + + public double SecondaryToPrimary(double secondaryValue) + { + EnsureValid(); + return secondaryValue * PrimaryNominal / SecondaryNominal; + } + + private void EnsureValid() + { + if (!IsValid) + throw new InvalidOperationException("A positive primary and secondary nominal value is required for conversion."); + } +} + +public sealed record SvMeasurementDomainValue +{ + public double WireValue { get; init; } + public string Unit { get; init; } = string.Empty; + public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.Unknown; + public double? PrimaryValue { get; init; } + public double? SecondaryEquivalentValue { get; init; } + public SvRatioSource RatioSource { get; init; } = SvRatioSource.Unknown; + public string RatioReference { get; init; } = string.Empty; + public string Diagnostic { get; init; } = string.Empty; +} + +/// +/// Produces primary and secondary-equivalent values without silently assuming a CT/VT ratio. +/// Installed-base 9-2LE engineering values are treated as primary only when the caller declares that domain. +/// +public static class SvMeasurementDomainResolver +{ + public static SvMeasurementDomainValue Resolve( + double wireValue, + string unit, + SvMeasurementValueDomain wireDomain, + SvMeasurementRatio? ratio) + { + if (!double.IsFinite(wireValue)) + throw new ArgumentOutOfRangeException(nameof(wireValue), "Measurement value must be finite."); + + if (wireDomain is SvMeasurementValueDomain.Unknown or SvMeasurementValueDomain.RawCount) + { + return new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + Diagnostic = "Primary and secondary values are unavailable because the wire measurement domain is not established." + }; + } + + if (ratio is null || !ratio.IsValid) + { + return new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = wireDomain == SvMeasurementValueDomain.PrimaryEngineering ? wireValue : null, + SecondaryEquivalentValue = wireDomain == SvMeasurementValueDomain.SecondaryEquivalent ? wireValue : null, + Diagnostic = "Only the declared wire-domain value is available; no verified CT/VT ratio was supplied." + }; + } + + return wireDomain switch + { + SvMeasurementValueDomain.PrimaryEngineering => new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = wireValue, + SecondaryEquivalentValue = ratio.PrimaryToSecondary(wireValue), + RatioSource = ratio.Source, + RatioReference = ratio.Reference, + Diagnostic = "Secondary-equivalent value was calculated from explicit ratio context." + }, + SvMeasurementValueDomain.SecondaryEquivalent => new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = ratio.SecondaryToPrimary(wireValue), + SecondaryEquivalentValue = wireValue, + RatioSource = ratio.Source, + RatioReference = ratio.Reference, + Diagnostic = "Primary value was calculated from explicit ratio context." + }, + _ => throw new ArgumentOutOfRangeException(nameof(wireDomain), wireDomain, "Unsupported measurement domain.") + }; + } +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs new file mode 100644 index 0000000..784f4b0 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs @@ -0,0 +1,252 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvQualityValidity +{ + Good, + Invalid, + Reserved, + Questionable +} + +public enum SvQualitySeverity +{ + Good, + Information, + Warning, + Bad, + Unknown +} + +public enum SvQualityWordPlacement +{ + AllZero, + TwoByteWord, + HighWord, + LowWord, + Ambiguous +} + +/// +/// Semantic interpretation of the IEC 61850 Quality bit field carried beside a sampled value. +/// Bit 13 is treated as the installed-base 9-2LE Derived extension only when explicitly enabled. +/// +public sealed record SvQualityState +{ + public ushort Word { get; init; } + public SvQualityWordPlacement Placement { get; init; } + public SvQualityValidity Validity { get; init; } + public bool Overflow { get; init; } + public bool OutOfRange { get; init; } + public bool BadReference { get; init; } + public bool Oscillatory { get; init; } + public bool Failure { get; init; } + public bool OldData { get; init; } + public bool Inconsistent { get; init; } + public bool Inaccurate { get; init; } + public bool IsSubstituted { get; init; } + public bool Test { get; init; } + public bool OperatorBlocked { get; init; } + public bool Derived { get; init; } + public bool HasReservedBits { get; init; } + public bool IsEncodingAmbiguous { get; init; } + public SvQualitySeverity Severity { get; init; } + public string Summary { get; init; } = string.Empty; + public IReadOnlyList ActiveFlags { get; init; } = Array.Empty(); + + public bool IsGood => Severity == SvQualitySeverity.Good; + public bool IsStrictlyUsable => Validity == SvQualityValidity.Good && + !Failure && + !BadReference && + !OldData && + !Inconsistent && + !HasReservedBits && + !IsEncodingAmbiguous; +} + +public static class SvQualityDecoder +{ + private const ushort StandardMask = 0x1FFF; + private const ushort LegacyDerivedMask = 0x2000; + private const ushort ReservedMask = 0xC000; + + public static bool TryDecodeHex(string? rawHex, out SvQualityState state, bool allowLegacyDerived = true) + { + state = Unknown("Quality bytes are unavailable."); + if (string.IsNullOrWhiteSpace(rawHex)) + return false; + + try + { + var bytes = Convert.FromHexString(rawHex.Trim()); + state = DecodeNetworkBytes(bytes, allowLegacyDerived); + return true; + } + catch (FormatException) + { + state = Unknown("Quality bytes are not valid hexadecimal data."); + return false; + } + } + + public static SvQualityState DecodeNetworkBytes(ReadOnlySpan bytes, bool allowLegacyDerived = true) + { + if (bytes.Length == 2) + return DecodeWord(BinaryPrimitives.ReadUInt16BigEndian(bytes), SvQualityWordPlacement.TwoByteWord, allowLegacyDerived); + + if (bytes.Length != 4) + return Unknown($"Expected two or four quality bytes, got {bytes.Length}."); + + var high = BinaryPrimitives.ReadUInt16BigEndian(bytes[..2]); + var low = BinaryPrimitives.ReadUInt16BigEndian(bytes[2..]); + + if (high == 0 && low == 0) + return DecodeWord(0, SvQualityWordPlacement.AllZero, allowLegacyDerived); + + var highPlausible = IsPlausible(high, allowLegacyDerived); + var lowPlausible = IsPlausible(low, allowLegacyDerived); + + if (high != 0 && low == 0 && highPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + if (low != 0 && high == 0 && lowPlausible) + return DecodeWord(low, SvQualityWordPlacement.LowWord, allowLegacyDerived); + if (highPlausible && !lowPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + if (lowPlausible && !highPlausible) + return DecodeWord(low, SvQualityWordPlacement.LowWord, allowLegacyDerived); + + if (high == low && highPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + + return new SvQualityState + { + Placement = SvQualityWordPlacement.Ambiguous, + Validity = SvQualityValidity.Reserved, + Severity = SvQualitySeverity.Unknown, + IsEncodingAmbiguous = true, + Summary = $"Quality placement ambiguous (high 0x{high:X4}, low 0x{low:X4})" + }; + } + + public static SvQualityState DecodeWord( + ushort word, + SvQualityWordPlacement placement = SvQualityWordPlacement.TwoByteWord, + bool allowLegacyDerived = true) + { + // IEC 61850 Quality validity is encoded as good=00, invalid=01, + // reserved=10 and questionable=11. + var validity = (word & 0x0003) switch + { + 0 => SvQualityValidity.Good, + 1 => SvQualityValidity.Invalid, + 2 => SvQualityValidity.Reserved, + _ => SvQualityValidity.Questionable + }; + + var flags = new List(); + AddFlag(flags, word, 2, "overflow"); + AddFlag(flags, word, 3, "out-of-range"); + AddFlag(flags, word, 4, "bad-reference"); + AddFlag(flags, word, 5, "oscillatory"); + AddFlag(flags, word, 6, "failure"); + AddFlag(flags, word, 7, "old-data"); + AddFlag(flags, word, 8, "inconsistent"); + AddFlag(flags, word, 9, "inaccurate"); + AddFlag(flags, word, 10, "substituted"); + AddFlag(flags, word, 11, "test"); + AddFlag(flags, word, 12, "operator-blocked"); + if (allowLegacyDerived) + AddFlag(flags, word, 13, "derived"); + + var hasReservedBits = (word & ReservedMask) != 0 || + (!allowLegacyDerived && (word & LegacyDerivedMask) != 0); + if (hasReservedBits) + flags.Add("reserved-bits"); + + var state = new SvQualityState + { + Word = word, + Placement = placement, + Validity = validity, + Overflow = IsSet(word, 2), + OutOfRange = IsSet(word, 3), + BadReference = IsSet(word, 4), + Oscillatory = IsSet(word, 5), + Failure = IsSet(word, 6), + OldData = IsSet(word, 7), + Inconsistent = IsSet(word, 8), + Inaccurate = IsSet(word, 9), + IsSubstituted = IsSet(word, 10), + Test = IsSet(word, 11), + OperatorBlocked = IsSet(word, 12), + Derived = allowLegacyDerived && IsSet(word, 13), + HasReservedBits = hasReservedBits, + ActiveFlags = flags.ToArray() + }; + + var severity = ResolveSeverity(state); + return state with + { + Severity = severity, + Summary = BuildSummary(state.Validity, state.ActiveFlags) + }; + } + + private static SvQualitySeverity ResolveSeverity(SvQualityState state) + { + if (state.HasReservedBits || + state.Validity is SvQualityValidity.Invalid or SvQualityValidity.Reserved || + state.Failure || + state.BadReference || + state.Inconsistent) + return SvQualitySeverity.Bad; + + if (state.Validity == SvQualityValidity.Questionable || + state.Overflow || + state.OutOfRange || + state.Oscillatory || + state.OldData || + state.Inaccurate || + state.IsSubstituted || + state.Test || + state.OperatorBlocked) + return SvQualitySeverity.Warning; + + if (state.Derived) + return SvQualitySeverity.Information; + + return SvQualitySeverity.Good; + } + + private static bool IsPlausible(ushort word, bool allowLegacyDerived) + { + var allowedMask = (ushort)(StandardMask | (allowLegacyDerived ? LegacyDerivedMask : 0)); + return (word & ~allowedMask) == 0; + } + + private static bool IsSet(ushort word, int bit) => (word & (1 << bit)) != 0; + + private static void AddFlag(ICollection flags, ushort word, int bit, string name) + { + if (IsSet(word, bit)) + flags.Add(name); + } + + private static string BuildSummary(SvQualityValidity validity, IReadOnlyCollection flags) + { + var validityText = validity.ToString(); + return flags.Count == 0 + ? validityText + : $"{validityText} · {string.Join(", ", flags)}"; + } + + private static SvQualityState Unknown(string reason) => new() + { + Placement = SvQualityWordPlacement.Ambiguous, + Validity = SvQualityValidity.Reserved, + Severity = SvQualitySeverity.Unknown, + IsEncodingAmbiguous = true, + Summary = reason + }; +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs b/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs new file mode 100644 index 0000000..0f84c7b --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs @@ -0,0 +1,156 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvSampleCounterTransitionKind +{ + Initial, + Continuous, + NormalWrap, + Gap, + Duplicate, + OutOfOrder, + Restart +} + +public sealed record SvSampleCounterTransition +{ + public SvSampleCounterTransitionKind Kind { get; init; } + public ushort Actual { get; init; } + public ushort? Previous { get; init; } + public ushort? Expected { get; init; } + public ushort? Wrap { get; init; } + public int MissingSamples { get; init; } + public string Detail { get; init; } = string.Empty; + public bool IsAnomaly => Kind is SvSampleCounterTransitionKind.Gap or + SvSampleCounterTransitionKind.Duplicate or + SvSampleCounterTransitionKind.OutOfOrder; +} + +/// +/// Stateful, profile-neutral smpCnt tracker. A known modulus is preferred; unknown traffic falls back to the ushort modulus. +/// Explicit restart hints prevent a publisher restart from being misclassified as out-of-order traffic. +/// +public sealed class SvSampleCounterTracker +{ + private ushort? _last; + private ushort? _expected; + + public ushort? Last => _last; + public ushort? Expected => _expected; + + public void Reset() + { + _last = null; + _expected = null; + } + + public SvSampleCounterTransition Observe(ushort actual, ushort? wrap, bool restartHint = false) + { + if (restartHint && _last.HasValue) + { + var previous = _last; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Restart, + Actual = actual, + Previous = previous, + Expected = null, + Wrap = wrap, + Detail = "The counter state was reset by trusted restart/configuration evidence." + }; + } + + if (!_last.HasValue) + { + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Initial, + Actual = actual, + Wrap = wrap, + Detail = "Initial sample counter observation." + }; + } + + var previousValue = _last.Value; + if (actual == previousValue) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Duplicate, + Actual = actual, + Previous = previousValue, + Expected = _expected, + Wrap = wrap, + Detail = $"Duplicate smpCnt {actual}." + }; + } + + var expectedValue = _expected ?? Next(previousValue, wrap); + if (actual == expectedValue) + { + var wrapped = actual < previousValue; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = wrapped ? SvSampleCounterTransitionKind.NormalWrap : SvSampleCounterTransitionKind.Continuous, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = wrapped ? "Normal smpCnt wrap." : "Continuous smpCnt transition." + }; + } + + var modulus = ResolveModulus(wrap); + var forward = DistanceForward(expectedValue, actual, modulus); + var backward = DistanceForward(actual, expectedValue, modulus); + var isForwardGap = forward > 0 && forward < backward; + + _last = actual; + _expected = Next(actual, wrap); + + if (isForwardGap) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Gap, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + MissingSamples = forward, + Detail = $"smpCnt advanced from expected {expectedValue} to {actual}; {forward} sample(s) were not observed." + }; + } + + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.OutOfOrder, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = $"smpCnt {actual} is behind expected {expectedValue}." + }; + } + + private static ushort Next(ushort value, ushort? wrap) + { + if (wrap is > 1) + return (ushort)((value + 1) % wrap.Value); + return unchecked((ushort)(value + 1)); + } + + private static int ResolveModulus(ushort? wrap) + => wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; + + private static int DistanceForward(ushort from, ushort to, int modulus) + { + var distance = ((int)to - from) % modulus; + return distance < 0 ? distance + modulus : distance; + } +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs b/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs new file mode 100644 index 0000000..824acf0 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs @@ -0,0 +1,181 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public sealed record SvTimebaseEvidence +{ + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public bool IsFixedLegacyProtectionLayout { get; init; } + public double? TrustedNominalFrequencyHz { get; init; } +} + +public sealed record SvTimebaseResolution +{ + public double? NominalFrequencyHz { get; init; } + public int? SamplesPerCycle { get; init; } + public ushort? SampleCounterWrap { get; init; } + public SvTimebaseSource Source { get; init; } = SvTimebaseSource.Unknown; + public string Reason { get; init; } = string.Empty; + public bool IsResolved => NominalFrequencyHz.HasValue && SamplesPerCycle.HasValue; +} + +public enum SvTimebaseSource +{ + Unknown, + TrustedContext, + DeclaredSamplesPerPeriod, + DeclaredSamplesPerSecond, + ObservedLegacyProtectionRate +} + +/// +/// Resolves frequency, samples-per-cycle and sample-counter wrap without silently assuming 50 Hz. +/// +public static class SvTimebaseResolver +{ + private static readonly double[] NominalFrequencyCandidates = [50.0, 60.0]; + private const double FrequencyToleranceHz = 1.0; + private const double RateToleranceFraction = 0.02; + + public static SvTimebaseResolution Resolve(SvTimebaseEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + if (evidence.TrustedNominalFrequencyHz is > 0 && + TryResolveSamplesPerCycle(evidence, evidence.TrustedNominalFrequencyHz.Value, out var trustedSamplesPerCycle, out var trustedWrap)) + { + return new SvTimebaseResolution + { + NominalFrequencyHz = evidence.TrustedNominalFrequencyHz, + SamplesPerCycle = trustedSamplesPerCycle, + SampleCounterWrap = trustedWrap, + Source = SvTimebaseSource.TrustedContext, + Reason = "Nominal frequency was supplied by trusted configuration context." + }; + } + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerCycle = evidence.DeclaredSampleRate.Value; + var estimatedFrequency = evidence.ObservedSamplesPerSecond.HasValue + ? SnapNominalFrequency(evidence.ObservedSamplesPerSecond.Value / samplesPerCycle) + : null; + var wrap = estimatedFrequency.HasValue + ? ToCounterWrap(samplesPerCycle * estimatedFrequency.Value) + : null; + + return new SvTimebaseResolution + { + NominalFrequencyHz = estimatedFrequency, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = wrap, + Source = SvTimebaseSource.DeclaredSamplesPerPeriod, + Reason = estimatedFrequency.HasValue + ? "Samples-per-period was declared and nominal frequency was confirmed by the observed rate." + : "Samples-per-period was declared; nominal frequency remains unknown until rate evidence is available." + }; + } + + if (evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerSecond = evidence.DeclaredSampleRate.Value; + var legacy = ResolveLegacyProtectionRate(samplesPerSecond, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.DeclaredSamplesPerSecond }; + + return new SvTimebaseResolution + { + SampleCounterWrap = ToCounterWrap(samplesPerSecond), + Source = SvTimebaseSource.DeclaredSamplesPerSecond, + Reason = "Samples-per-second was declared, but samples-per-cycle cannot be inferred safely for an unknown profile." + }; + } + + if (evidence.ObservedSamplesPerSecond is > 0) + { + var legacy = ResolveLegacyProtectionRate(evidence.ObservedSamplesPerSecond.Value, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.ObservedLegacyProtectionRate }; + } + + return new SvTimebaseResolution + { + Source = SvTimebaseSource.Unknown, + Reason = "No trustworthy timebase could be resolved without making a hidden 50/60 Hz assumption." + }; + } + + private static SvTimebaseResolution? ResolveLegacyProtectionRate(double samplesPerSecond, bool fixedLegacyLayout) + { + if (!fixedLegacyLayout) + return null; + + foreach (var nominal in NominalFrequencyCandidates) + { + const int samplesPerCycle = 80; + var expected = nominal * samplesPerCycle; + if (Math.Abs(samplesPerSecond - expected) > expected * RateToleranceFraction) + continue; + + return new SvTimebaseResolution + { + NominalFrequencyHz = nominal, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = ToCounterWrap(expected), + Reason = $"The fixed protection layout and observed/declared rate match {samplesPerCycle} samples/cycle at {nominal:0} Hz." + }; + } + + return null; + } + + private static bool TryResolveSamplesPerCycle( + SvTimebaseEvidence evidence, + double nominalFrequencyHz, + out int samplesPerCycle, + out ushort? wrap) + { + samplesPerCycle = 0; + wrap = null; + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + samplesPerCycle = evidence.DeclaredSampleRate.Value; + wrap = ToCounterWrap(samplesPerCycle * nominalFrequencyHz); + return true; + } + + var samplesPerSecond = evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0 + ? evidence.DeclaredSampleRate.Value + : evidence.ObservedSamplesPerSecond; + if (!samplesPerSecond.HasValue || samplesPerSecond.Value <= 0) + return false; + + var calculated = samplesPerSecond.Value / nominalFrequencyHz; + var rounded = (int)Math.Round(calculated); + if (rounded <= 0 || Math.Abs(calculated - rounded) > 0.05) + return false; + + samplesPerCycle = rounded; + wrap = ToCounterWrap(samplesPerSecond.Value); + return true; + } + + private static double? SnapNominalFrequency(double measured) + { + foreach (var nominal in NominalFrequencyCandidates) + { + if (Math.Abs(measured - nominal) <= FrequencyToleranceHz) + return nominal; + } + + return null; + } + + private static ushort? ToCounterWrap(double samplesPerSecond) + { + if (samplesPerSecond <= 1 || samplesPerSecond > ushort.MaxValue) + return null; + return (ushort)Math.Round(samplesPerSecond); + } +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs new file mode 100644 index 0000000..e4affc3 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs @@ -0,0 +1,97 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvComparisonMode { Strict, Compatible } +public enum SvConfigurationFindingSeverity { Info, Warning, Error } + +public sealed record SvExpectedStreamConfiguration +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + 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 int? PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public IReadOnlyList DataSetSignature { get; init; } = Array.Empty(); +} + +public sealed record SvConfigurationFinding( + SvConfigurationFindingSeverity Severity, + string Code, + string Field, + string Expected, + string Observed, + string Message); + +public sealed record SvConfigurationComparisonResult +{ + public SvComparisonMode Mode { get; init; } + public IReadOnlyList Findings { get; init; } = 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 => IsExactMatch ? "Exact" : ErrorCount > 0 ? CountText(ErrorCount, "error") : WarningCount > 0 ? CountText(WarningCount, "warning") : CountText(InfoCount, "info"); + private static string CountText(int count, string label) => $"{count} {label}{(count == 1 ? string.Empty : "s")}"; +} + +public sealed class SvConfigurationComparer +{ + public SvConfigurationComparisonResult Compare(SvExpectedStreamConfiguration expected, SvObservedStreamFacts observed, SvComparisonMode mode) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(observed); + var findings = new List(); + CompareNullable("SV_ETHERTYPE", "EtherType", expected.EtherType, observed.EtherType, value => $"0x{value:X4}", mode, findings); + CompareNullable("SV_APPID", "APPID", expected.AppId, observed.AppId, value => $"0x{value:X4}", mode, findings); + CompareText("SV_DEST_MAC", "Destination MAC", expected.DestinationMac, observed.DestinationMac, NormalizeMacAddress, mode, findings); + CompareNullable("SV_VLAN_ID", "VLAN ID", expected.VlanId, observed.VlanId, value => value.ToString(), mode, findings); + CompareNullable("SV_VLAN_PRIORITY", "VLAN priority", expected.VlanPriority, observed.VlanPriority, value => value.ToString(), mode, findings); + CompareText("SV_ID", "svID", expected.SvId, observed.SvId, NormalizeIdentifier, mode, findings); + CompareText("SV_DATASET", "Dataset reference", expected.DataSetReference, observed.DataSetReference, NormalizeIdentifier, mode, findings); + CompareNullable("SV_CONFREV", "confRev", expected.ConfigurationRevision, observed.ConfigurationRevision, value => value.ToString(), mode, findings); + CompareNullable("SV_ASDU_COUNT", "ASDU per frame", expected.AsduPerFrame, observed.AsduPerFrame, value => value.ToString(), mode, findings); + CompareNullable("SV_PAYLOAD_LENGTH", "Payload bytes per ASDU", expected.PayloadBytesPerAsdu, observed.PayloadBytesPerAsdu, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_RATE", "Declared sample rate", expected.DeclaredSampleRate, observed.DeclaredSampleRate, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_MODE", "Declared sample mode", expected.DeclaredSampleMode, observed.DeclaredSampleMode, value => value.ToString(), mode, findings); + CompareSignature(expected.DataSetSignature, observed.DataSetSignature, mode, findings); + return new SvConfigurationComparisonResult { Mode = mode, Findings = findings }; + } + + private static void CompareText(string code, string field, string expected, string observed, Func normalize, SvComparisonMode mode, List findings) + { + if (string.IsNullOrWhiteSpace(expected)) return; + if (string.IsNullOrWhiteSpace(observed)) { findings.Add(Missing(code, field, expected, mode)); return; } + if (!string.Equals(normalize(expected), normalize(observed), StringComparison.Ordinal)) findings.Add(Mismatch(code, field, expected, observed, mode)); + } + + private static void CompareNullable(string code, string field, T? expected, T? observed, Func format, SvComparisonMode mode, List findings) where T : struct, IEquatable + { + if (!expected.HasValue) return; + if (!observed.HasValue) { findings.Add(Missing(code, field, format(expected.Value), mode)); return; } + if (!expected.Value.Equals(observed.Value)) findings.Add(Mismatch(code, field, format(expected.Value), format(observed.Value), mode)); + } + + private static void CompareSignature(IReadOnlyList expected, IReadOnlyList observed, SvComparisonMode mode, List findings) + { + if (expected.Count == 0) return; + if (observed.Count == 0) { findings.Add(Missing("SV_DATASET_SIGNATURE", "Dataset signature", SignatureText(expected), mode)); return; } + if (!expected.Select(ToKey).SequenceEqual(observed.Select(ToKey), StringComparer.Ordinal)) findings.Add(Mismatch("SV_DATASET_SIGNATURE", "Dataset signature", SignatureText(expected), SignatureText(observed), mode)); + } + + private static SvConfigurationFinding Missing(string code, string field, string expected, SvComparisonMode mode) + => new(Severity(mode), $"{code}_MISSING", field, expected, "-", $"Observed traffic does not expose the expected {field}. Capture and decoding remain active."); + private static SvConfigurationFinding Mismatch(string code, string field, string expected, string observed, SvComparisonMode mode) + => new(Severity(mode), $"{code}_MISMATCH", field, expected, observed, $"Configured {field} differs from observed traffic. Capture and decoding remain active."); + private static SvConfigurationFindingSeverity Severity(SvComparisonMode mode) => mode == SvComparisonMode.Strict ? SvConfigurationFindingSeverity.Error : SvConfigurationFindingSeverity.Warning; + private static string NormalizeMacAddress(string value) => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); + private static string NormalizeIdentifier(string value) => (value ?? string.Empty).Trim(); + private static string SignatureText(IReadOnlyList signature) => string.Join(", ", signature.Select(item => item.NormalizedBType)); + private static string ToKey(SvDatasetElementSignature item) => $"{item.NormalizedBType}|{item.NormalizedCdc}|{item.IsQuality}|{item.IsTimestamp}"; +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs b/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs new file mode 100644 index 0000000..0c08d14 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs @@ -0,0 +1,39 @@ +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) + => string.IsNullOrWhiteSpace(sampleMode) ? null : 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/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs b/src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs new file mode 100644 index 0000000..d42c4d2 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs @@ -0,0 +1,225 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public sealed record SvFrameObservation +{ + public DateTimeOffset Timestamp { get; init; } + public ushort EtherType { get; init; } + public ushort AppId { get; init; } + 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 PayloadBytesPerAsdu { get; init; } + public IReadOnlyList SampleCounts { get; init; } = Array.Empty(); + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + + public int AsduPerFrame => SampleCounts.Count; + + public void Validate() + { + if (SampleCounts.Count == 0) + throw new ArgumentException("An SV frame observation requires at least one ASDU sample counter."); + if (PayloadBytesPerAsdu <= 0) + throw new ArgumentOutOfRangeException(nameof(PayloadBytesPerAsdu)); + } +} + +public sealed class SvObservationAccumulator +{ + public const int DefaultMaximumObservations = 4096; + public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(5); + + private readonly object _gate = new(); + private readonly Queue _observations = new(); + private readonly int _maximumObservations; + private readonly TimeSpan _maximumAge; + + public SvObservationAccumulator(int maximumObservations = DefaultMaximumObservations, TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) + throw new ArgumentOutOfRangeException(nameof(maximumObservations), "At least two observations are required for rate analysis."); + _maximumAge = maximumAge ?? DefaultMaximumAge; + if (_maximumAge <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maximumAge), "Observation age must be greater than zero."); + _maximumObservations = maximumObservations; + } + + public int Count { get { lock (_gate) return _observations.Count; } } + public int MaximumObservations => _maximumObservations; + public TimeSpan MaximumAge => _maximumAge; + + public void Add(SvFrameObservation observation) + { + ArgumentNullException.ThrowIfNull(observation); + observation.Validate(); + lock (_gate) + { + _observations.Enqueue(observation); + TrimWindow(observation.Timestamp); + } + } + + public void Clear() { lock (_gate) _observations.Clear(); } + + public SvObservedStreamFacts BuildFacts() + { + SvFrameObservation[] snapshot; + lock (_gate) snapshot = _observations.ToArray(); + if (snapshot.Length == 0) + return new SvObservedStreamFacts(); + + var ordered = snapshot.OrderBy(item => item.Timestamp).ToArray(); + var diagnostics = new List(); + var first = ordered[0]; + var last = ordered[^1]; + var durationSeconds = (last.Timestamp - first.Timestamp).TotalSeconds; + double? framesPerSecond = null; + double? samplesPerSecond = null; + if (ordered.Length >= 2 && durationSeconds > 0) + { + framesPerSecond = (ordered.Length - 1) / durationSeconds; + samplesPerSecond = framesPerSecond * ordered.Average(item => item.AsduPerFrame); + } + else + { + diagnostics.Add("At least two timestamped frames are required to estimate stream rate."); + } + + var counter = AnalyzeCounters(ordered, diagnostics); + return new SvObservedStreamFacts + { + EtherType = StableValue(ordered.Select(item => item.EtherType), "EtherType", diagnostics), + AppId = StableValue(ordered.Select(item => item.AppId), "APPID", diagnostics), + DestinationMac = StableString(ordered.Select(item => item.DestinationMac), "destination MAC", diagnostics), + VlanId = StableNullableValue(ordered.Select(item => item.VlanId), "VLAN ID", diagnostics), + VlanPriority = StableNullableValue(ordered.Select(item => item.VlanPriority), "VLAN priority", diagnostics), + SvId = StableString(ordered.Select(item => item.SvId), "svID", diagnostics), + DataSetReference = StableString(ordered.Select(item => item.DataSetReference), "dataset reference", diagnostics), + ConfigurationRevision = StableValue(ordered.Select(item => item.ConfigurationRevision), "confRev", diagnostics), + AsduPerFrame = StableValue(ordered.Select(item => item.AsduPerFrame), "ASDU count", diagnostics), + PayloadBytesPerAsdu = StableValue(ordered.Select(item => item.PayloadBytesPerAsdu), "payload length", diagnostics), + ObservedFramesPerSecond = framesPerSecond, + ObservedSamplesPerSecond = samplesPerSecond, + ObservedCounterWrap = counter.Wrap, + CounterTransitions = counter.Summary, + DeclaredSampleRate = StableNullableValue(ordered.Select(item => item.DeclaredSampleRate), "declared sample rate", diagnostics), + DeclaredSampleMode = StableNullableValue(ordered.Select(item => item.DeclaredSampleMode), "declared sample mode", diagnostics), + NominalFrequencyHz = StableNullableValue(ordered.Select(item => item.NominalFrequencyHz), "nominal frequency", diagnostics), + DataSetSignature = StableSignature(ordered.Select(item => item.DataSetSignature), diagnostics), + Provenance = BuildProvenance(), + ObservationCount = ordered.Length, + FirstTimestamp = first.Timestamp, + LastTimestamp = last.Timestamp, + Diagnostics = diagnostics + }; + } + + private void TrimWindow(DateTimeOffset newestTimestamp) + { + while (_observations.Count > _maximumObservations) _observations.Dequeue(); + var oldestAllowed = newestTimestamp - _maximumAge; + while (_observations.Count > 0 && _observations.Peek().Timestamp < oldestAllowed) _observations.Dequeue(); + } + + private static (int? Wrap, SvCounterTransitionSummary Summary) AnalyzeCounters(IReadOnlyList observations, List diagnostics) + { + var sampleCounts = observations.SelectMany(item => item.SampleCounts).ToArray(); + if (sampleCounts.Length < 2) + return (null, new SvCounterTransitionSummary()); + + var sequential = 0; var gaps = 0; var duplicates = 0; var outOfOrderOrReset = 0; var confirmedWraps = 0; + var wrapCandidates = new HashSet(); + for (var index = 1; index < sampleCounts.Length; index++) + { + var previous = sampleCounts[index - 1]; + var current = sampleCounts[index]; + if (current == previous) { duplicates++; continue; } + if (current == previous + 1) { sequential++; continue; } + if (current > previous + 1) { gaps++; continue; } + var recovery = index + 1 < sampleCounts.Length && sampleCounts[index + 1] == current + 1; + if (current == 0 && recovery && previous > 1) { confirmedWraps++; wrapCandidates.Add(previous + 1); continue; } + outOfOrderOrReset++; + } + + int? wrap = wrapCandidates.Count == 1 ? wrapCandidates.Single() : null; + if (wrapCandidates.Count > 1) diagnostics.Add($"Multiple sample-counter wrap candidates were observed: {string.Join(", ", wrapCandidates.Order())}."); + if (gaps > 0) diagnostics.Add($"Observed {gaps} forward sample-counter gap transition(s)."); + if (duplicates > 0) diagnostics.Add($"Observed {duplicates} duplicate sample-counter transition(s)."); + if (outOfOrderOrReset > 0) diagnostics.Add($"Observed {outOfOrderOrReset} out-of-order or reset transition(s); these were not classified as wraps."); + return (wrap, new SvCounterTransitionSummary + { + SequentialCount = sequential, + GapCount = gaps, + DuplicateCount = duplicates, + OutOfOrderOrResetCount = outOfOrderOrReset, + ConfirmedWrapCount = confirmedWraps + }); + } + + private static IReadOnlyDictionary BuildProvenance() + => new Dictionary(StringComparer.Ordinal) + { + [nameof(SvObservedStreamFacts.EtherType)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AppId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DestinationMac)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanPriority)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.SvId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DataSetReference)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ConfigurationRevision)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AsduPerFrame)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.PayloadBytesPerAsdu)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleRate)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleMode)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ObservedFramesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedSamplesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedCounterWrap)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.CounterTransitions)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.NominalFrequencyHz)] = SvFactSource.TrustedContext, + [nameof(SvObservedStreamFacts.DataSetSignature)] = SvFactSource.SclDerived + }; + + private static T? StableValue(IEnumerable values, string field, List diagnostics) where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static T? StableNullableValue(IEnumerable values, string field, List diagnostics) where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static string StableString(IEnumerable values, string field, List diagnostics) + { + var distinct = values.Select(value => value ?? string.Empty).Distinct(StringComparer.Ordinal).ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return string.Empty; + } + + private static IReadOnlyList StableSignature(IEnumerable> signatures, List diagnostics) + { + var materialized = signatures.ToArray(); + var normalized = materialized.Select(signature => signature.Select(ToKey).ToArray()).ToArray(); + if (normalized.Length == 0 || normalized.All(signature => signature.Length == 0)) return Array.Empty(); + var first = normalized[0]; + if (normalized.All(signature => signature.SequenceEqual(first, StringComparer.Ordinal))) return materialized[0].ToArray(); + diagnostics.Add("Observed dataset signature changed within the analysis window."); + return Array.Empty(); + } + + private static string ToKey(SvDatasetElementSignature element) + => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs b/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs new file mode 100644 index 0000000..10594e1 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs @@ -0,0 +1,154 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +/// +/// Evidence-weighted, vendor-neutral profile detector. It evaluates only observable wire, +/// SCL, capture-rate, and trusted-context facts; manufacturer identity is never an input. +/// +public sealed class SvProfileDetector +{ + private const int EtherTypeWeight = 5; + private const int AsduWeight = 12; + private const int PayloadWeight = 18; + private const int DataSetCountWeight = 12; + private const int DataSetSignatureWeight = 25; + private const int SamplingRateWeight = 25; + private const int NominalFrequencyWeight = 8; + private const int CounterWrapWeight = 15; + + public IReadOnlyList Detect(SvObservedStreamFacts facts, IEnumerable profiles) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profiles); + return profiles.Select(profile => Evaluate(facts, profile)) + .OrderByDescending(result => Rank(result.Confidence)) + .ThenByDescending(result => result.ScorePercent) + .ThenByDescending(result => result.EvaluatedWeight) + .ThenBy(result => result.Profile.DisplayName, StringComparer.Ordinal) + .ToArray(); + } + + public SvProfileDetectionResult? DetectBest(SvObservedStreamFacts facts, IEnumerable profiles) + => Detect(facts, profiles).FirstOrDefault(); + + public SvProfileDetectionResult Evaluate(SvObservedStreamFacts facts, SvProfileDefinition profile) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profile); + profile.Validate(); + var evidence = new List(); + Compare("EtherType", profile.ExpectedEtherType, facts.EtherType, EtherTypeWeight, value => $"0x{value:X4}", evidence); + CompareAllowed("ASDU per frame", profile.AllowedAsduPerFrame, facts.AsduPerFrame, AsduWeight, evidence); + Compare("Payload bytes per ASDU", profile.ExpectedPayloadBytesPerAsdu, facts.PayloadBytesPerAsdu, PayloadWeight, value => value.ToString(), evidence); + Compare("Dataset element count", profile.ExpectedDataSetElementCount, facts.DataSetSignature.Count > 0 ? facts.DataSetSignature.Count : null, DataSetCountWeight, value => value.ToString(), evidence); + CompareSignature(profile.ExpectedDataSetSignature, facts.DataSetSignature, evidence); + CompareSampling(profile, facts, evidence); + CompareAllowedDouble("Nominal frequency", profile.AllowedNominalFrequenciesHz, facts.NominalFrequencyHz, NominalFrequencyWeight, profile.RateTolerancePercent, evidence); + Compare("Sample-counter wrap", profile.ExpectedCounterWrap, facts.ObservedCounterWrap, CounterWrapWeight, value => value.ToString(), evidence); + + var evaluated = evidence.Where(item => item.Outcome != SvProfileEvidenceOutcome.Unknown).Sum(item => item.Weight); + var matched = evidence.Where(item => item.Outcome == SvProfileEvidenceOutcome.Match).Sum(item => item.Weight); + var conflict = evidence.Where(item => item.Outcome == SvProfileEvidenceOutcome.Conflict).Sum(item => item.Weight); + var score = evaluated == 0 ? 0 : Math.Round((double)matched / evaluated * 100, 2); + var raw = ResolveRawConfidence(score, matched, conflict, evaluated, + Match(evidence, "Dataset signature"), + Match(evidence, "Observed samples per second") || Match(evidence, "Samples per cycle")); + return new SvProfileDetectionResult + { + Profile = profile, + RawConfidence = raw, + ScorePercent = score, + MatchedWeight = matched, + ConflictWeight = conflict, + EvaluatedWeight = evaluated, + Evidence = evidence + }; + } + + private static SvProfileConfidence ResolveRawConfidence(double score, int matched, int conflict, int evaluated, bool signature, bool sampling) + { + if (evaluated == 0) return SvProfileConfidence.Unknown; + if (conflict >= matched && conflict > 0) return SvProfileConfidence.Conflict; + if (conflict == 0 && score >= 90 && evaluated >= 70 && signature && sampling) return SvProfileConfidence.Confirmed; + if (score >= 70 && evaluated >= 40) return SvProfileConfidence.Likely; + if (score >= 45 && evaluated >= 15) return SvProfileConfidence.Possible; + return conflict > 0 ? SvProfileConfidence.Conflict : SvProfileConfidence.Unknown; + } + + private static void CompareSampling(SvProfileDefinition profile, SvObservedStreamFacts facts, List evidence) + { + if (profile.SamplingBasis == SvSamplingBasis.SamplesPerSecond && profile.ExpectedSamplesPerSecond.HasValue) + CompareApproximate("Observed samples per second", profile.ExpectedSamplesPerSecond.Value, facts.ObservedSamplesPerSecond, SamplingRateWeight, profile.RateTolerancePercent, evidence); + else if (profile.SamplingBasis == SvSamplingBasis.SamplesPerCycle && profile.ExpectedSamplesPerCycle.HasValue) + { + if (!facts.ObservedSamplesPerSecond.HasValue || !facts.NominalFrequencyHz.HasValue) + evidence.Add(new("Samples per cycle", SvProfileEvidenceOutcome.Unknown, SamplingRateWeight, profile.ExpectedSamplesPerCycle.Value.ToString("0.###"), "-", "Observed rate and nominal frequency are both required.")); + else + CompareApproximate("Samples per cycle", profile.ExpectedSamplesPerCycle.Value, facts.ObservedSamplesPerSecond.Value / facts.NominalFrequencyHz.Value, SamplingRateWeight, profile.RateTolerancePercent, evidence); + } + } + + private static void CompareSignature(IReadOnlyList expected, IReadOnlyList observed, List evidence) + { + if (expected.Count == 0) return; + if (observed.Count == 0) + { + evidence.Add(new("Dataset signature", SvProfileEvidenceOutcome.Unknown, DataSetSignatureWeight, Signature(expected), "-", "No dataset signature is available for this observation window.")); + return; + } + var matches = expected.Select(Key).SequenceEqual(observed.Select(Key), StringComparer.Ordinal); + evidence.Add(new("Dataset signature", matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, DataSetSignatureWeight, + Signature(expected), Signature(observed), matches ? "Dataset element order and types match the profile definition." : "Dataset element order or types conflict with the profile definition.")); + } + + private static void CompareAllowed(string field, IReadOnlyList allowed, int? observed, int weight, List evidence) + { + if (allowed.Count == 0) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, string.Join("/", allowed), "-", $"Observed {field} is unavailable.")); return; } + var matches = allowed.Contains(observed.Value); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, string.Join("/", allowed), observed.Value.ToString(), matches ? $"Observed {field} is allowed." : $"Observed {field} is not allowed by the profile definition.")); + } + + private static void CompareAllowedDouble(string field, IReadOnlyList allowed, double? observed, int weight, double tolerance, List evidence) + { + if (allowed.Count == 0) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, string.Join("/", allowed), "-", $"Observed {field} is unavailable.")); return; } + var matches = allowed.Any(value => Within(value, observed.Value, tolerance)); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, string.Join("/", allowed), observed.Value.ToString("0.###"), matches ? $"Observed {field} is allowed." : $"Observed {field} conflicts with the allowed values.")); + } + + private static void CompareApproximate(string field, double expected, double? observed, int weight, double tolerance, List evidence) + { + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, expected.ToString("0.###"), "-", $"Observed {field} is unavailable.")); return; } + var matches = Within(expected, observed.Value, tolerance); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, expected.ToString("0.###"), observed.Value.ToString("0.###"), matches ? $"Observed {field} is within {tolerance:0.###}% tolerance." : $"Observed {field} is outside {tolerance:0.###}% tolerance.")); + } + + private static void Compare(string field, T? expected, T? observed, int weight, Func format, List evidence) where T : struct, IEquatable + { + if (!expected.HasValue) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, format(expected.Value), "-", $"Observed {field} is unavailable.")); return; } + var matches = expected.Value.Equals(observed.Value); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, format(expected.Value), format(observed.Value), matches ? $"Observed {field} matches." : $"Observed {field} conflicts with the profile definition.")); + } + + private static bool Within(double expected, double observed, double tolerance) => expected == 0 ? observed == 0 : Math.Abs(observed - expected) / Math.Abs(expected) * 100 <= tolerance; + private static bool Match(IEnumerable evidence, string field) => evidence.Any(item => item.Field == field && item.Outcome == SvProfileEvidenceOutcome.Match); + private static string Signature(IReadOnlyList value) => string.Join(", ", value.Select(element => element.NormalizedBType)); + private static string Key(SvDatasetElementSignature element) => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; + private static int Rank(SvProfileConfidence value) => value switch { SvProfileConfidence.Confirmed => 4, SvProfileConfidence.Likely => 3, SvProfileConfidence.Possible => 2, SvProfileConfidence.Unknown => 1, _ => 0 }; +} + +public static class SvProfileCatalog +{ + public static SvProfileDefinition GenericSclLayer2 { get; } = new() + { + 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("ariec61850-engine", "Generic Layer-2 SV mechanisms implemented by the shared engine without a profile-specific conformance claim.", SvProfileEvidenceStatus.ImplementedGeneric)] + }; + public static IReadOnlyList BuiltIn { get; } = [GenericSclLayer2]; +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs b/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs new file mode 100644 index 0000000..70eed0a --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs @@ -0,0 +1,238 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvSamplingBasis +{ + Unspecified, + SamplesPerCycle, + SamplesPerSecond, + Custom +} + +public enum SvProfileEvidenceStatus +{ + ResearchCandidate, + ImplementedGeneric, + VerifiedStandard, + VerifiedCapture, + VerifiedLab +} + +public enum SvProfileConfidence +{ + Unknown, + Possible, + Likely, + Confirmed, + Conflict +} + +public enum SvProfileEvidenceOutcome +{ + Match, + Conflict, + Unknown +} + +public enum SvFactSource +{ + Unknown, + WireObserved, + CaptureCalculated, + SclDerived, + TrustedContext, + ProfileInferred +} + +public sealed record SvProfileSourceEvidence( + string SourceId, + string Description, + SvProfileEvidenceStatus Status); + +public sealed record SvDatasetElementSignature +{ + public string BType { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public bool IsQuality { get; init; } + public bool IsTimestamp { get; init; } + + public string NormalizedBType => Normalize(BType); + public string NormalizedCdc => Normalize(Cdc); + + private static string Normalize(string value) + => new((value ?? string.Empty) + .Where(char.IsLetterOrDigit) + .Select(char.ToUpperInvariant) + .ToArray()); +} + +public sealed record SvCounterTransitionSummary +{ + public int SequentialCount { get; init; } + public int GapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderOrResetCount { get; init; } + public int ConfirmedWrapCount { get; init; } +} + +public sealed record SvObservedStreamFacts +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + 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 int? PayloadBytesPerAsdu { get; init; } + public double? ObservedFramesPerSecond { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public int? ObservedCounterWrap { get; init; } + public SvCounterTransitionSummary CounterTransitions { get; init; } = new(); + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + public IReadOnlyDictionary Provenance { get; init; } + = new Dictionary(StringComparer.Ordinal); + public int ObservationCount { get; init; } + public DateTimeOffset? FirstTimestamp { get; init; } + public DateTimeOffset? LastTimestamp { get; init; } + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed record SvProfileDefinition +{ + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Family { get; init; } = string.Empty; + public SvSamplingBasis SamplingBasis { get; init; } + public ushort? ExpectedEtherType { get; init; } + public IReadOnlyList AllowedAsduPerFrame { get; init; } = Array.Empty(); + public int? ExpectedPayloadBytesPerAsdu { get; init; } + public int? ExpectedDataSetElementCount { get; init; } + public IReadOnlyList ExpectedDataSetSignature { get; init; } + = Array.Empty(); + public double? ExpectedSamplesPerCycle { get; init; } + public double? ExpectedSamplesPerSecond { get; init; } + public IReadOnlyList AllowedNominalFrequenciesHz { get; init; } = Array.Empty(); + public int? ExpectedCounterWrap { get; init; } + public double RateTolerancePercent { get; init; } = 1.0; + public SvProfileEvidenceStatus EvidenceStatus { get; init; } = SvProfileEvidenceStatus.ResearchCandidate; + public IReadOnlyList Sources { get; init; } + = Array.Empty(); + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Id)) + throw new InvalidOperationException("SV profile definition requires a stable ID."); + if (string.IsNullOrWhiteSpace(DisplayName)) + throw new InvalidOperationException($"SV profile '{Id}' requires a display name."); + if (Sources.Count == 0) + throw new InvalidOperationException($"SV profile '{Id}' requires at least one evidence source."); + if (Sources.Any(source => string.IsNullOrWhiteSpace(source.SourceId) || string.IsNullOrWhiteSpace(source.Description))) + throw new InvalidOperationException($"SV profile '{Id}' contains incomplete evidence-source metadata."); + if (RateTolerancePercent < 0 || RateTolerancePercent > 100) + throw new InvalidOperationException($"SV profile '{Id}' has an invalid rate tolerance."); + if (AllowedAsduPerFrame.Any(value => value <= 0)) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid ASDU-per-frame value."); + if (AllowedAsduPerFrame.Count != AllowedAsduPerFrame.Distinct().Count()) + throw new InvalidOperationException($"SV profile '{Id}' contains duplicate ASDU-per-frame values."); + if (ExpectedPayloadBytesPerAsdu is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid payload length."); + if (ExpectedDataSetElementCount is < 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid dataset element count."); + if (ExpectedDataSetElementCount.HasValue && + ExpectedDataSetSignature.Count > 0 && + ExpectedDataSetElementCount.Value != ExpectedDataSetSignature.Count) + throw new InvalidOperationException($"SV profile '{Id}' dataset count does not match its ordered signature."); + if (ExpectedCounterWrap is <= 1) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sample-counter wrap."); + if (AllowedNominalFrequenciesHz.Any(value => value <= 0 || double.IsNaN(value) || double.IsInfinity(value))) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid nominal frequency."); + if (ExpectedSamplesPerCycle is <= 0 || ExpectedSamplesPerSecond is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sampling expectation."); + if (ExpectedSamplesPerCycle.HasValue && ExpectedSamplesPerSecond.HasValue) + throw new InvalidOperationException($"SV profile '{Id}' cannot define both samples-per-cycle and samples-per-second expectations."); + + switch (SamplingBasis) + { + case SvSamplingBasis.SamplesPerCycle when !ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-cycle expectation."); + case SvSamplingBasis.SamplesPerSecond when !ExpectedSamplesPerSecond.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-second expectation."); + case SvSamplingBasis.SamplesPerCycle when ExpectedSamplesPerSecond.HasValue: + case SvSamplingBasis.SamplesPerSecond when ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' sampling expectation conflicts with its sampling basis."); + } + } +} + +public sealed record SvProfileMatchEvidence( + string Field, + SvProfileEvidenceOutcome Outcome, + int Weight, + string Expected, + string Observed, + string Message); + +public sealed record SvProfileDetectionResult +{ + private SvProfileConfidence _rawConfidence; + + public SvProfileDefinition Profile { get; init; } = new(); + public SvProfileConfidence RawConfidence + { + get => _rawConfidence; + init => _rawConfidence = value; + } + public SvProfileConfidence Confidence + { + get => SvProfileConfidencePolicy.ApplyEvidenceMaturityCeiling(_rawConfidence, Profile.EvidenceStatus); + init => _rawConfidence = value; + } + public double ScorePercent { get; init; } + public int MatchedWeight { get; init; } + public int ConflictWeight { get; init; } + public int EvaluatedWeight { get; init; } + public IReadOnlyList Evidence { get; init; } + = Array.Empty(); + + public bool HasConflicts => Evidence.Any(item => item.Outcome == SvProfileEvidenceOutcome.Conflict); +} + +public static class SvProfileConfidencePolicy +{ + public static SvProfileConfidence ApplyEvidenceMaturityCeiling( + SvProfileConfidence confidence, + SvProfileEvidenceStatus evidenceStatus) + { + if (confidence is SvProfileConfidence.Unknown or SvProfileConfidence.Conflict) + return confidence; + + var ceiling = evidenceStatus switch + { + SvProfileEvidenceStatus.ResearchCandidate => SvProfileConfidence.Possible, + SvProfileEvidenceStatus.ImplementedGeneric => SvProfileConfidence.Likely, + SvProfileEvidenceStatus.VerifiedStandard or + SvProfileEvidenceStatus.VerifiedCapture or + SvProfileEvidenceStatus.VerifiedLab => SvProfileConfidence.Confirmed, + _ => SvProfileConfidence.Possible + }; + + return Rank(confidence) <= Rank(ceiling) ? confidence : ceiling; + } + + private static int Rank(SvProfileConfidence confidence) + => confidence switch + { + SvProfileConfidence.Unknown => 0, + SvProfileConfidence.Possible => 1, + SvProfileConfidence.Likely => 2, + SvProfileConfidence.Confirmed => 3, + SvProfileConfidence.Conflict => 4, + _ => 0 + }; +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs new file mode 100644 index 0000000..0f70e6d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -0,0 +1,184 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvObservationInputKind { Unknown, LiveCapture, PcapReplay } + +public sealed record SvObservedStreamKey +{ + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public ushort AppId { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public string Id => $"SV|{AppId:X4}|{SourceMac}|{DestinationMac}|{VlanId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}|{SvId}|{DataSetReference}"; + + public static SvObservedStreamKey FromFrame(SampledValuesFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + var first = frame.Pdu.Asdus.FirstOrDefault() ?? throw new ArgumentException("An observed SV frame requires at least one ASDU.", nameof(frame)); + return new SvObservedStreamKey + { + SourceMac = frame.Source.ToString(), DestinationMac = frame.Destination.ToString(), VlanId = frame.Vlan?.VlanId, + AppId = frame.AppId, SvId = first.SvId, DataSetReference = first.DataSetReference + }; + } +} + +public sealed record SvStreamObservationSnapshot +{ + public SvObservedStreamKey Key { get; init; } = new(); + public SvObservedStreamFacts Facts { get; init; } = new(); + public IReadOnlyList InputKinds { get; init; } = Array.Empty(); + 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? _expected; + private SvComparisonMode _mode = SvComparisonMode.Compatible; + public StreamState(int maximum, TimeSpan age) => Accumulator = new SvObservationAccumulator(maximum, age); + public SvObservationAccumulator Accumulator { get; } + public SvObservationInputKind LastInputKind { get; private set; } + public bool IsBoundToScl { get; private set; } + public string ControlBlockReference { get; private set; } = string.Empty; + + public void Add(SvFrameObservation observation, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, SvComparisonMode mode, IEnumerable diagnostics) + { + Accumulator.Add(observation); + lock (_gate) + { + LastInputKind = inputKind; + _inputKinds.Add(inputKind); + if (profile is not null) + { + IsBoundToScl = true; + ControlBlockReference = profile.Stream.ControlBlockReference; + _expected = SvExpectedStreamConfigurationFactory.Create(profile); + _mode = mode; + } + foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item))) + { + if (_diagnostics.Contains(diagnostic, StringComparer.Ordinal)) continue; + _diagnostics.Enqueue(diagnostic); + while (_diagnostics.Count > 16) _diagnostics.Dequeue(); + } + } + } + + public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) + { + var facts = Accumulator.BuildFacts(); + lock (_gate) + { + var comparison = _expected is null ? null : new SvConfigurationComparer().Compare(_expected, facts, _mode); + return new SvStreamObservationSnapshot + { + Key = key, + Facts = facts, + InputKinds = _inputKinds.OrderBy(item => item).ToArray(), + LastInputKind = LastInputKind, + IsBoundToScl = IsBoundToScl, + ControlBlockReference = ControlBlockReference, + ExpectedConfiguration = _expected, + ConfigurationComparison = comparison, + ProfileDetection = new SvProfileDetector().DetectBest(facts, SvProfileCatalog.BuiltIn), + Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() + }; + } + } + } + + private readonly ConcurrentDictionary _streams = new(); + private readonly int _maximum; + private readonly TimeSpan _age; + + public SvStreamObservationManager(int maximumObservations = DefaultMaximumObservations, TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) throw new ArgumentOutOfRangeException(nameof(maximumObservations)); + _age = maximumAge ?? DefaultMaximumAge; + if (_age <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(maximumAge)); + _maximum = maximumObservations; + } + + public int Count => _streams.Count; + + public bool TryObserve(DateTimeOffset timestamp, SampledValuesFrame frame, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, out SvStreamObservationSnapshot snapshot, double? nominalFrequencyHz = null, SvComparisonMode comparisonMode = SvComparisonMode.Compatible) + { + ArgumentNullException.ThrowIfNull(frame); + snapshot = new(); + var asdus = frame.Pdu.Asdus; + var first = asdus.FirstOrDefault(); + if (first is null || first.SamplePayload.Length <= 0) return false; + + var key = SvObservedStreamKey.FromFrame(frame); + var diagnostics = ValidateFrameConsistency(asdus).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 observation = new SvFrameObservation + { + Timestamp = timestamp, EtherType = 0x88BA, AppId = frame.AppId, DestinationMac = frame.Destination.ToString(), + VlanId = frame.Vlan?.VlanId, VlanPriority = frame.Vlan?.PriorityCodePoint, SvId = first.SvId, + DataSetReference = first.DataSetReference, ConfigurationRevision = first.ConfigurationRevision, + PayloadBytesPerAsdu = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length, + SampleCounts = asdus.Select(item => item.SampleCount).ToArray(), + DeclaredSampleRate = StableNullable(asdus.Select(item => item.SampleRate)), + DeclaredSampleMode = StableNullable(asdus.Select(item => item.SampleMode)), + NominalFrequencyHz = nominalFrequencyHz, DataSetSignature = signature + }; + var state = _streams.GetOrAdd(key, _ => new StreamState(_maximum, _age)); + state.Add(observation, inputKind, boundProfile, comparisonMode, diagnostics); + snapshot = state.Snapshot(key); + return true; + } + + public bool TryGetSnapshot(SvObservedStreamKey key, out SvStreamObservationSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(key); + if (_streams.TryGetValue(key, out var state)) { snapshot = state.Snapshot(key); return true; } + snapshot = new(); return false; + } + + public IReadOnlyList SnapshotAll() + => _streams.Select(pair => pair.Value.Snapshot(pair.Key)).OrderBy(item => item.Key.AppId).ThenBy(item => item.Key.SvId, StringComparer.Ordinal).ThenBy(item => item.Key.DataSetReference, StringComparer.Ordinal).ToArray(); + public void Clear() => _streams.Clear(); + + private static SampledValuesPublisherProfile? ValidateProfileBinding(SampledValuesFrame frame, SampledValuesPublisherProfile? profile, ICollection diagnostics) + { + if (profile is null) return null; + if (profile.AppId == frame.AppId && string.Equals(profile.Destination.ToString(), frame.Destination.ToString(), StringComparison.OrdinalIgnoreCase) && profile.Vlan?.VlanId == frame.Vlan?.VlanId) 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(); var first = asdus[0]; + if (asdus.Any(item => item.SvId != first.SvId)) diagnostics.Add("ASDUs inside one Ethernet frame expose different svID values."); + if (asdus.Any(item => item.DataSetReference != first.DataSetReference)) diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references."); + if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision)) diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values."); + if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1) diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths."); + return diagnostics; + } + + private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry) => new() { BType = entry.BType, Cdc = entry.Cdc, IsQuality = entry.IsQuality, IsTimestamp = entry.IsTimestamp }; + private static ushort? StableNullable(IEnumerable values) { var distinct = values.Distinct().ToArray(); return distinct.Length == 1 ? distinct[0] : null; } +} diff --git a/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs new file mode 100644 index 0000000..ec7fd3b --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +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 (SchemaVersion != CurrentSchemaVersion) 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."); + } +} + +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 = new List(); + Text(reportChanges, "Software", "Version", baseline.Software.Version, candidate.Software.Version, SvEvidenceChangeSeverity.Info, "Software version changed."); + Text(reportChanges, "Software", "Commit", baseline.Software.Commit, candidate.Software.Commit, SvEvidenceChangeSeverity.Info, "Build commit changed."); + Health(reportChanges, "Report", baseline.Summary.Health, candidate.Summary.Health); + var streams = CompareStreams(baseline.Streams, candidate.Streams); + var all = reportChanges.Concat(streams.SelectMany(stream => stream.Changes)).ToArray(); + var result = 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(x => x.Kind == SvEvidenceChangeKind.Added), RemovedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Removed), + ChangedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Changed), UnchangedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Unchanged), + InfoChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Info), WarningChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Warning), ErrorChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Error) + } + }; + result.Validate(); return result; + } + + private static IReadOnlyList CompareStreams(IReadOnlyList baseline, IReadOnlyList candidate) + { + var output = new List(); var used = new HashSet(StringComparer.Ordinal); + foreach (var source in baseline) + { + var target = candidate.FirstOrDefault(item => item.Key == source.Key) ?? candidate.FirstOrDefault(item => !used.Contains(item.Key) && LogicalKey(item) == LogicalKey(source)); + if (target is null) { output.Add(Removed(source)); continue; } + used.Add(target.Key); output.Add(Pair(source, target)); + } + foreach (var target in candidate.Where(item => !used.Contains(item.Key))) output.Add(Added(target)); + return output.OrderBy(item => item.Identity.AppId).ThenBy(item => item.Identity.SvId, StringComparer.Ordinal).ThenBy(item => item.Kind).ToArray(); + } + + private static SvSubscriberStreamComparison Pair(SvSubscriberStreamEvidence baseline, SvSubscriberStreamEvidence candidate) + { + var changes = new List(); + Health(changes, "Stream", baseline.Health, candidate.Health); + Text(changes, "Identity", "Source MAC", baseline.Identity.SourceMac, candidate.Identity.SourceMac, SvEvidenceChangeSeverity.Info, "Publisher source MAC changed while logical identity remained stable."); + Issue(changes, "Sequence gaps", baseline.Runtime.SequenceGapCount, candidate.Runtime.SequenceGapCount, SvEvidenceChangeSeverity.Warning); + Issue(changes, "Duplicates", baseline.Runtime.DuplicateCount, candidate.Runtime.DuplicateCount, SvEvidenceChangeSeverity.Warning); + Issue(changes, "Out-of-order", baseline.Runtime.OutOfOrderCount, candidate.Runtime.OutOfOrderCount, SvEvidenceChangeSeverity.Error); + Issue(changes, "Payload issues", baseline.Runtime.PayloadIssueCount, candidate.Runtime.PayloadIssueCount, SvEvidenceChangeSeverity.Error); + Rate(changes, "Observed samples/s", baseline.Observation.Facts.ObservedSamplesPerSecond, candidate.Observation.Facts.ObservedSamplesPerSecond); + return new SvSubscriberStreamComparison + { + ComparisonKey = $"PAIR|{baseline.Key}|{candidate.Key}", LogicalStreamKey = LogicalKey(candidate), + 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 SvSubscriberStreamComparison Added(SvSubscriberStreamEvidence stream) => new() { ComparisonKey = $"ADDED|{stream.Key}", LogicalStreamKey = LogicalKey(stream), Kind = SvEvidenceChangeKind.Added, Severity = SvEvidenceChangeSeverity.Info, CandidateStreamKey = stream.Key, Identity = stream.Identity, Changes = [Change("Stream", "Presence", SvEvidenceChangeSeverity.Info, "absent", "present", "Logical stream was added.")] }; + private static SvSubscriberStreamComparison Removed(SvSubscriberStreamEvidence stream) => new() { ComparisonKey = $"REMOVED|{stream.Key}", LogicalStreamKey = LogicalKey(stream), Kind = SvEvidenceChangeKind.Removed, Severity = SvEvidenceChangeSeverity.Error, BaselineStreamKey = stream.Key, Identity = stream.Identity, Changes = [Change("Stream", "Presence", SvEvidenceChangeSeverity.Error, "present", "absent", "Logical stream is missing from the candidate evidence.")] }; + private static string LogicalKey(SvSubscriberStreamEvidence stream) => $"{stream.Identity.AppId:X4}|{stream.Identity.DestinationMac}|{stream.Identity.VlanId}|{stream.Identity.SvId}|{stream.Identity.DataSetReference}"; + 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 void Health(ICollection changes, string category, string baseline, string candidate) + { + if (string.Equals(baseline, candidate, StringComparison.OrdinalIgnoreCase)) return; + var rank = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["IDLE"] = 0, ["GOOD"] = 1, ["WARN"] = 2, ["BAD"] = 3, ["ERROR"] = 4 }; + var severity = rank.GetValueOrDefault(candidate) > rank.GetValueOrDefault(baseline) ? SvEvidenceChangeSeverity.Error : SvEvidenceChangeSeverity.Info; + changes.Add(Change(category, "Health", severity, baseline, candidate, severity == SvEvidenceChangeSeverity.Error ? "Health regressed." : "Health changed.")); + } + private static void Text(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)) changes.Add(Change(category, field, severity, baseline, candidate, message)); } + private static void Issue(ICollection changes, string field, int baseline, int candidate, SvEvidenceChangeSeverity severity) { if (baseline != candidate) changes.Add(Change("Runtime", field, candidate > baseline ? severity : SvEvidenceChangeSeverity.Info, baseline.ToString(CultureInfo.InvariantCulture), candidate.ToString(CultureInfo.InvariantCulture), candidate > baseline ? $"{field} increased." : $"{field} decreased.")); } + private static void Rate(ICollection changes, string field, double? baseline, double? candidate) { if (!baseline.HasValue || !candidate.HasValue) return; var tolerance = Math.Abs(baseline.Value) * RateTolerancePercent / 100.0; if (Math.Abs(candidate.Value - baseline.Value) > tolerance) changes.Add(Change("Rate", field, SvEvidenceChangeSeverity.Warning, baseline.Value.ToString("0.###", CultureInfo.InvariantCulture), candidate.Value.ToString("0.###", CultureInfo.InvariantCulture), "Observed rate moved outside the comparison tolerance.")); } + private static SvEvidenceFieldChange Change(string category, string field, SvEvidenceChangeSeverity severity, string? baseline, string? candidate, string message) => new() { Category = category, Field = field, Severity = severity, Baseline = baseline ?? string.Empty, Candidate = candidate ?? string.Empty, Message = message }; +} + +public static class SvSubscriberEvidenceComparisonSerializer +{ + private static readonly JsonSerializerOptions Options = CreateOptions(); + public static string ToJson(SvSubscriberEvidenceComparison comparison) { ArgumentNullException.ThrowIfNull(comparison); comparison.Validate(); return JsonSerializer.Serialize(comparison, Options); } + public static SvSubscriberEvidenceComparison FromJson(string json) { if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("SV comparison JSON cannot be empty.", nameof(json)); var value = JsonSerializer.Deserialize(json, Options) ?? throw new InvalidDataException("SV comparison JSON did not contain a document."); value.Validate(); return value; } + public static string ToMarkdown(SvSubscriberEvidenceComparison comparison) + { + ArgumentNullException.ThrowIfNull(comparison); comparison.Validate(); var b = new StringBuilder(); + b.AppendLine("# ARSVIN Subscriber Evidence Comparison").AppendLine(); + b.AppendLine(comparison.Summary.HasRegressions ? "## REVIEW REQUIRED" : "## NO REGRESSION DETECTED").AppendLine(); + b.Append("- Baseline commit: ").AppendLine(comparison.Baseline.Commit).Append("- Candidate commit: ").AppendLine(comparison.Candidate.Commit).AppendLine(); + foreach (var stream in comparison.Streams) + { + b.Append("## ").Append(stream.Kind).Append(" — ").AppendLine(stream.Identity.SvId).AppendLine(); + foreach (var change in stream.Changes) b.Append("- **").Append(change.Field).Append("** · ").Append(change.Severity).Append(" · ").Append(change.Baseline).Append(" → ").Append(change.Candidate).Append(" — ").AppendLine(change.Message); + b.AppendLine(); + } + return b.ToString(); + } + private static JsonSerializerOptions CreateOptions() { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); return options; } +} diff --git a/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs new file mode 100644 index 0000000..b248e8d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs @@ -0,0 +1,173 @@ +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 (SchemaVersion != CurrentSchemaVersion) 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 Options = CreateOptions(); + + public static string ToJson(SvSubscriberEvidenceReport report) { ArgumentNullException.ThrowIfNull(report); report.Validate(); return JsonSerializer.Serialize(report, Options); } + 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, Options) ?? 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 b = new StringBuilder(); + b.AppendLine("# ARSVIN Subscriber Evidence Report").AppendLine(); + b.AppendLine("> Receiver-side engineering evidence. This document is not a formal IEC 61850 conformance certificate.").AppendLine(); + b.AppendLine("## Report metadata").AppendLine(); + Table(b, ("Schema", report.SchemaVersion), ("Generated", report.GeneratedAt.ToString("O", CultureInfo.InvariantCulture)), ("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"))); + b.AppendLine("## Summary").AppendLine(); + Table(b, ("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(CultureInfo.InvariantCulture)), ("Runtime issues", report.Summary.RuntimeIssueCount.ToString(CultureInfo.InvariantCulture)), ("Configuration findings", report.Summary.ConfigurationFindingCount.ToString(CultureInfo.InvariantCulture))); + b.AppendLine("## Streams").AppendLine(); + foreach (var stream in report.Streams) AppendStream(b, stream); + return b.ToString(); + } + + private static void AppendStream(StringBuilder b, SvSubscriberStreamEvidence stream) + { + b.Append("## 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture)).Append(" — ").AppendLine(Empty(stream.Identity.SvId)).AppendLine(); + Table(b, ("Stream key", stream.Key), ("Health", stream.Health), ("Health detail", stream.HealthDetail), ("Source MAC", stream.Identity.SourceMac), ("Destination MAC", stream.Identity.DestinationMac), ("Dataset", stream.Identity.DataSetReference), ("confRev", Value(stream.Identity.ConfigurationRevision)), ("nofASDU", stream.Identity.AsduPerFrame.ToString(CultureInfo.InvariantCulture)), ("SCL binding", stream.Observation.IsBoundToScl ? Empty(stream.Observation.ControlBlockReference) : "not bound")); + b.AppendLine("### Observed facts and provenance").AppendLine(); + b.AppendLine("| Fact | Value | Source |").AppendLine("|---|---|---|"); + foreach (var item in stream.Observation.FactProvenance.OrderBy(item => item.Key, StringComparer.Ordinal)) + b.Append("| ").Append(Cell(item.Key)).Append(" | ").Append(Cell(FactValue(stream.Observation.Facts, item.Key))).Append(" | ").Append(Cell(item.Value.ToString())).AppendLine(" |"); + b.AppendLine(); + b.AppendLine("### Expected SCL configuration").AppendLine(); + if (stream.Observation.ExpectedConfiguration is { } expected) Table(b, ("APPID", Value(expected.AppId)), ("Destination MAC", expected.DestinationMac), ("svID", expected.SvId), ("Dataset", expected.DataSetReference), ("confRev", Value(expected.ConfigurationRevision)), ("ASDU/frame", Value(expected.AsduPerFrame))); + else b.AppendLine("Not configured.").AppendLine(); + b.AppendLine("### Configuration comparison").AppendLine(); + var comparison = stream.Observation.ConfigurationComparison; + if (comparison is null) b.AppendLine("Not configured.").AppendLine(); else { b.AppendLine(comparison.Summary).AppendLine(); foreach (var finding in comparison.Findings) b.Append("- **").Append(finding.Code).Append("** · ").Append(finding.Field).Append(" · ").AppendLine(finding.Message); b.AppendLine(); } + b.AppendLine("### Profile detection evidence").AppendLine(); + var detection = stream.Observation.ProfileDetection; + if (detection is null) b.AppendLine("Unknown.").AppendLine(); else { b.Append("- Profile: ").AppendLine(detection.Profile.DisplayName); b.Append("- Confidence: ").AppendLine(detection.Confidence.ToString()); foreach (var evidence in detection.Evidence) b.Append("- ").Append(evidence.Field).Append(" · ").Append(evidence.Outcome).Append(" · ").AppendLine(evidence.Message); b.AppendLine(); } + b.AppendLine("### Phasors").AppendLine(); + if (stream.Phasors.Count == 0) b.AppendLine("None.").AppendLine(); else { b.AppendLine("| Channel | Kind | RMS | Peak | Angle |").AppendLine("|---|---|---:|---:|---:|"); foreach (var p in stream.Phasors) b.Append("| ").Append(Cell(p.Channel)).Append(" | ").Append(Cell(p.Kind)).Append(" | ").Append(p.Rms.ToString("0.###", CultureInfo.InvariantCulture)).Append(" | ").Append(p.Peak.ToString("0.###", CultureInfo.InvariantCulture)).Append(" | ").Append(p.AngleDegrees.ToString("0.###", CultureInfo.InvariantCulture)).AppendLine(" |"); b.AppendLine(); } + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); return options; + } + private static void Table(StringBuilder b, params (string Key, string Value)[] rows) { b.AppendLine("| Field | Value |").AppendLine("|---|---|"); foreach (var row in rows) b.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(row.Value)).AppendLine(" |"); b.AppendLine(); } + private static string Empty(string? value, string fallback = "-") => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + private static string Value(T? value) where T : struct => value?.ToString() ?? "-"; + private static string Cell(string? value) => Empty(value).Replace("|", "\\|", StringComparison.Ordinal).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); + private static string FactValue(SvObservedStreamFacts facts, string name) => name switch + { + nameof(SvObservedStreamFacts.AppId) => Value(facts.AppId), nameof(SvObservedStreamFacts.EtherType) => Value(facts.EtherType), nameof(SvObservedStreamFacts.DestinationMac) => facts.DestinationMac, + nameof(SvObservedStreamFacts.VlanId) => Value(facts.VlanId), nameof(SvObservedStreamFacts.VlanPriority) => Value(facts.VlanPriority), nameof(SvObservedStreamFacts.SvId) => facts.SvId, + nameof(SvObservedStreamFacts.DataSetReference) => facts.DataSetReference, nameof(SvObservedStreamFacts.ConfigurationRevision) => Value(facts.ConfigurationRevision), + nameof(SvObservedStreamFacts.AsduPerFrame) => Value(facts.AsduPerFrame), nameof(SvObservedStreamFacts.PayloadBytesPerAsdu) => Value(facts.PayloadBytesPerAsdu), + nameof(SvObservedStreamFacts.ObservedFramesPerSecond) => facts.ObservedFramesPerSecond?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.ObservedSamplesPerSecond) => facts.ObservedSamplesPerSecond?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.ObservedCounterWrap) => Value(facts.ObservedCounterWrap), nameof(SvObservedStreamFacts.DeclaredSampleRate) => Value(facts.DeclaredSampleRate), + nameof(SvObservedStreamFacts.DeclaredSampleMode) => Value(facts.DeclaredSampleMode), nameof(SvObservedStreamFacts.NominalFrequencyHz) => facts.NominalFrequencyHz?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.DataSetSignature) => string.Join(", ", facts.DataSetSignature.Select(item => item.NormalizedBType)), _ => "-" + }; +} diff --git a/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs b/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs new file mode 100644 index 0000000..cfcd6de --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs @@ -0,0 +1,38 @@ +namespace AR.Iec61850.SampledValues; + +public enum SampleCounterMode +{ + FreeRun, + SecondAligned +} + +public static class SampleCounterPolicy +{ + public static ushort InitialSampleCount(DateTimeOffset timestamp, double sampleRateHz, ushort? wrap, SampleCounterMode mode) + { + if (mode == SampleCounterMode.FreeRun || sampleRateHz <= 0) + return 0; + + var samplesPerSecond = wrap is > 1 ? wrap.Value : sampleRateHz; + if (samplesPerSecond <= 0) + return 0; + + var seconds = timestamp.TimeOfDay.TotalSeconds; + var fraction = seconds - Math.Floor(seconds); + var sample = (long)Math.Floor(fraction * samplesPerSecond); + var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1L; + sample %= modulo; + if (sample < 0) + sample += modulo; + return (ushort)sample; + } + + public static ushort Increment(ushort current, ushort? wrap, int step = 1) + { + if (step <= 0) + return current; + + var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; + return (ushort)((current + step) % modulo); + } +} diff --git a/src/AR.Iec61850/SampledValues/SampledValueQuality.cs b/src/AR.Iec61850/SampledValues/SampledValueQuality.cs new file mode 100644 index 0000000..b40bc10 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValueQuality.cs @@ -0,0 +1,85 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues; + +/// +/// Compact IEC 61850 quality bit helper used by Sampled Values payload publishers. +/// It exposes the common simulation controls while preserving the standardized network encoding. +/// +public readonly record struct SampledValueQuality( + SampledValueValidity Validity, + bool Overflow = false, + bool OutOfRange = false, + bool BadReference = false, + bool Oscillatory = false, + bool Failure = false, + bool OldData = false, + bool Inconsistent = false, + bool Inaccurate = false, + bool Test = false, + bool OperatorBlocked = false) +{ + public static SampledValueQuality Good { get; } = new(SampledValueValidity.Good); + public static SampledValueQuality Invalid { get; } = new(SampledValueValidity.Invalid); + public static SampledValueQuality Questionable { get; } = new(SampledValueValidity.Questionable); + public static SampledValueQuality TestGood { get; } = new(SampledValueValidity.Good, Test: true); + public static SampledValueQuality OldDataGood { get; } = new(SampledValueValidity.Good, OldData: true); + public static SampledValueQuality OperatorBlockedGood { get; } = new(SampledValueValidity.Good, OperatorBlocked: true); + + public uint ToUInt32() + { + var value = (uint)Validity; + if (Overflow) value |= 1u << 2; + if (OutOfRange) value |= 1u << 3; + if (BadReference) value |= 1u << 4; + if (Oscillatory) value |= 1u << 5; + if (Failure) value |= 1u << 6; + if (OldData) value |= 1u << 7; + if (Inconsistent) value |= 1u << 8; + if (Inaccurate) value |= 1u << 9; + if (Test) value |= 1u << 11; + if (OperatorBlocked) value |= 1u << 12; + return value; + } + + public byte[] ToBytes(int width = 4) + { + if (width <= 0) + return []; + + Span encoded = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(encoded, ToUInt32()); + + if (width == 4) + return encoded.ToArray(); + + var result = new byte[width]; + encoded[..Math.Min(4, width)].CopyTo(result); + return result; + } + + public static SampledValueQuality FromUInt32(uint encoded) + { + var validity = (SampledValueValidity)(encoded & 0x03); + return new SampledValueQuality( + validity, + Overflow: (encoded & (1u << 2)) != 0, + OutOfRange: (encoded & (1u << 3)) != 0, + BadReference: (encoded & (1u << 4)) != 0, + Oscillatory: (encoded & (1u << 5)) != 0, + Failure: (encoded & (1u << 6)) != 0, + OldData: (encoded & (1u << 7)) != 0, + Inconsistent: (encoded & (1u << 8)) != 0, + Inaccurate: (encoded & (1u << 9)) != 0, + Test: (encoded & (1u << 11)) != 0, + OperatorBlocked: (encoded & (1u << 12)) != 0); + } +} + +public enum SampledValueValidity : uint +{ + Good = 0, + Invalid = 1, + Reserved = 2, + Questionable = 3 +} diff --git a/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs b/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs new file mode 100644 index 0000000..5e93ce7 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs @@ -0,0 +1,69 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues; + +public sealed record SampledValuesFramePreview( + string ControlBlockReference, + string SvId, + string DataSetReference, + ushort AppId, + MacAddress Destination, + VlanTag? Vlan, + ushort NoAsdu, + int PayloadBytesPerAsdu, + double SampleRateHz, + double PublicationRateHz, + int EstimatedEthernetBytes, + double EstimatedBandwidthBitsPerSecond) +{ + public string Summary => + $"{ControlBlockReference}: svID={SvId}, APPID=0x{AppId:X4}, dst={Destination}, " + + $"{(Vlan is null ? "untagged" : $"VID={Vlan.Value.VlanId}/PCP={Vlan.Value.PriorityCodePoint}")}, " + + $"nofASDU={NoAsdu}, sample={SampleRateHz:0.###} fps, publish={PublicationRateHz:0.###} fps, " + + $"payload={PayloadBytesPerAsdu} B/ASDU, frame≈{EstimatedEthernetBytes} B, bw≈{EstimatedBandwidthBitsPerSecond / 1000.0:0.###} kbps"; + + public static SampledValuesFramePreview FromStream(SclSampledValuesStream stream, double sampleRateHz) + { + ArgumentNullException.ThrowIfNull(stream); + var profile = SampledValuesPublisherProfile.Create(stream); + return FromProfile(profile, sampleRateHz); + } + + public static SampledValuesFramePreview FromProfile(SampledValuesPublisherProfile profile, double sampleRateHz) + { + ArgumentNullException.ThrowIfNull(profile); + var noAsdu = profile.AsduPerFrame; + var publicationRateHz = SampledValuesPublisherProfile.ResolvePublicationRate(sampleRateHz, noAsdu); + var estimatedBytes = EstimateEthernetFrameBytes(profile, noAsdu); + return new SampledValuesFramePreview( + profile.Stream.ControlBlockReference, + profile.Stream.SvId, + profile.Stream.DataSetReference, + profile.AppId, + profile.Destination, + profile.Vlan, + noAsdu, + profile.PayloadLayout.PayloadByteLength, + sampleRateHz, + publicationRateHz, + estimatedBytes, + estimatedBytes * 8.0 * publicationRateHz); + } + + public static int EstimateEthernetFrameBytes(SampledValuesPublisherProfile profile, ushort? noAsduOverride = null) + { + ArgumentNullException.ThrowIfNull(profile); + var noAsdu = noAsduOverride ?? profile.AsduPerFrame; + var payloads = Enumerable.Range(0, noAsdu) + .Select(_ => new byte[profile.PayloadLayout.PayloadByteLength]) + .ToArray(); + var frame = profile.BuildEthernetFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + samplePayloads: payloads, + referenceTime: null, + sampleSynchronization: 2); + return frame.Length; + } +} diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs b/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs index 05276da..35e7dd3 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs @@ -19,16 +19,19 @@ public static byte[] BuildPayload(SampledValuesPayloadLayout layout, IReadOnlyLi throw new ArgumentException($"SV payload value count mismatch. Expected {layout.Elements.Count}, got {values.Count}.", nameof(values)); var payload = new byte[layout.PayloadByteLength]; - for (var i = 0; i < layout.Elements.Count; i++) + for (var index = 0; index < layout.Elements.Count; index++) { - var element = layout.Elements[i]; - WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[i]); + var element = layout.Elements[index]; + WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[index]); } return payload; } - public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec61850UtcTime? timestamp = null) + public static byte[] BuildDefaultPayload( + SampledValuesPayloadLayout layout, + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) { ArgumentNullException.ThrowIfNull(layout); @@ -38,8 +41,12 @@ public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec6 var payload = new byte[layout.PayloadByteLength]; foreach (var element in layout.Elements) { + var destination = payload.AsSpan(element.Offset, element.Width); if (element.Kind == SampledValuePayloadElementKind.Timestamp && timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(payload.AsSpan(element.Offset, element.Width)); + BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(destination); + + if (element.Kind == SampledValuePayloadElementKind.Quality) + (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); } return payload; @@ -50,25 +57,30 @@ public static byte[] BuildDemoPayload( long sampleIndex, double sampleRateHz, double nominalHz, - Iec61850UtcTime? timestamp = null) + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) { ArgumentNullException.ThrowIfNull(layout); if (!layout.IsFullySupported) throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - - if (nominalHz <= 0) - throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0."); + if (!double.IsFinite(sampleRateHz) || sampleRateHz <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0 and finite."); + if (!double.IsFinite(nominalHz) || nominalHz <= 0) + throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0 and finite."); var payload = new byte[layout.PayloadByteLength]; foreach (var element in layout.Elements) { var destination = payload.AsSpan(element.Offset, element.Width); - if (element.Kind == SampledValuePayloadElementKind.Quality || - element.Kind == SampledValuePayloadElementKind.BitString || + if (element.Kind == SampledValuePayloadElementKind.Quality) + { + (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); + continue; + } + + if (element.Kind == SampledValuePayloadElementKind.BitString || element.Kind == SampledValuePayloadElementKind.EntryTime) { continue; @@ -216,7 +228,6 @@ private static void CopyRawPayloadBytes(Span destination, MmsDataValue val if (raw.Length > destination.Length) throw new ArgumentException($"SV raw value has {raw.Length} bytes but the payload slot has {destination.Length} bytes."); - raw.CopyTo(destination); } @@ -239,13 +250,13 @@ private static ulong ToUInt64(MmsDataValue value) }; private static float ToSingle(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f + => value.Kind == MmsDataKind.FloatingPoint && value.Value is float number + ? number : Convert.ToSingle(value.Value, CultureInfo.InvariantCulture); private static double ToDouble(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f + => value.Kind == MmsDataKind.FloatingPoint && value.Value is float number + ? number : Convert.ToDouble(value.Value, CultureInfo.InvariantCulture); private static long ComputeDemoValue(SampledValuePayloadElement element, long sampleIndex, double sampleRateHz, double nominalHz) diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs b/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs new file mode 100644 index 0000000..6211cf5 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs @@ -0,0 +1,13 @@ +using AR.Iec61850.Capture; + +namespace AR.Iec61850.SampledValues; + +public static class SampledValuesPcapExporter +{ + public static void WriteGeneratedFrames(string filePath, IEnumerable<(DateTimeOffset Timestamp, byte[] Frame)> frames) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentNullException.ThrowIfNull(frames); + PcapWriter.WriteAll(filePath, frames.Select(frame => new PcapPacket(frame.Timestamp, frame.Frame))); + } +} diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs new file mode 100644 index 0000000..1587cd4 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using System.Text; + +namespace AR.Iec61850.SampledValues; + +/// +/// One diagnostic item recorded by a Sampled Values publisher preflight or runtime workflow. +/// +public sealed record SampledValuesEvidenceFinding( + string Severity, + string Area, + string Message, + string Detail); + +/// +/// Evidence describing one configured Sampled Values publisher stream. +/// This records transmitter intent and local calculations; it does not prove remote subscription. +/// +public sealed record SampledValuesEvidenceStream( + string SlotName, + bool IsEnabled, + string ControlBlockReference, + string SvId, + string DataSetReference, + string AppId, + string SourceMac, + string DestinationMac, + string Vlan, + double SampleRateHz, + double PublicationRateHz, + ushort NoAsdu, + int PayloadBytesPerAsdu, + int EstimatedEthernetBytes, + double EstimatedBandwidthBitsPerSecond, + string SignalSource, + string Quality, + string SyncMode, + string Status, + IReadOnlyList Findings); + +/// +/// Reusable transmitter-side evidence contract for a Sampled Values publisher application. +/// +public sealed record SampledValuesPublisherEvidenceReport( + string ToolName, + string ToolVersion, + DateTimeOffset CreatedAt, + string SclPath, + string Adapter, + string Mode, + string TxTiming, + string SafetyBoundary, + IReadOnlyList Streams, + IReadOnlyList GlobalFindings); + +public static class SampledValuesPublisherEvidenceReportWriter +{ + public static string ToMarkdown(SampledValuesPublisherEvidenceReport report) + { + ArgumentNullException.ThrowIfNull(report); + + var builder = new StringBuilder(); + builder.Append("# ") + .Append(EmptyAsDash(report.ToolName)) + .AppendLine(" Sampled Values Publisher Evidence Report"); + builder.AppendLine(); + builder.AppendLine("> TX-side publisher evidence only. This report records configured intent and local transmitter observations; it does not prove remote subscription, calibrated accuracy, or formal IEC 61850 conformance."); + builder.AppendLine(); + AppendField(builder, "Tool", $"{report.ToolName} {report.ToolVersion}".Trim()); + AppendField(builder, "Created", report.CreatedAt.ToString("O", CultureInfo.InvariantCulture)); + AppendField(builder, "SCL", EmptyAsDash(report.SclPath)); + AppendField(builder, "Adapter", EmptyAsDash(report.Adapter)); + AppendField(builder, "Mode", EmptyAsDash(report.Mode)); + AppendField(builder, "TX timing", EmptyAsDash(report.TxTiming)); + AppendField(builder, "Boundary", EmptyAsDash(report.SafetyBoundary)); + builder.AppendLine(); + + builder.AppendLine("## Streams"); + builder.AppendLine(); + if (report.Streams.Count == 0) + { + builder.AppendLine("No publisher streams were enabled."); + builder.AppendLine(); + } + else + { + foreach (var stream in report.Streams) + AppendStream(builder, stream); + } + + builder.AppendLine("## Global findings"); + builder.AppendLine(); + AppendFindings(builder, report.GlobalFindings); + + return builder.ToString(); + } + + private static void AppendStream(StringBuilder builder, SampledValuesEvidenceStream stream) + { + builder.Append("### ").AppendLine(EmptyAsDash(stream.SlotName)); + builder.AppendLine(); + AppendField(builder, "Enabled", stream.IsEnabled ? "yes" : "no"); + AppendField(builder, "Status", EmptyAsDash(stream.Status)); + AppendField(builder, "Control block", EmptyAsDash(stream.ControlBlockReference)); + AppendField(builder, "svID", EmptyAsDash(stream.SvId)); + AppendField(builder, "DataSet", EmptyAsDash(stream.DataSetReference)); + AppendField(builder, "APPID", EmptyAsDash(stream.AppId)); + AppendField(builder, "Source MAC", EmptyAsDash(stream.SourceMac)); + AppendField(builder, "Destination MAC", EmptyAsDash(stream.DestinationMac)); + AppendField(builder, "VLAN", EmptyAsDash(stream.Vlan)); + AppendField(builder, "Sample rate", $"{stream.SampleRateHz:0.###} sample/s"); + AppendField(builder, "Publication rate", $"{stream.PublicationRateHz:0.###} frame/s"); + AppendField(builder, "nofASDU", stream.NoAsdu.ToString(CultureInfo.InvariantCulture)); + AppendField(builder, "Payload", $"{stream.PayloadBytesPerAsdu} byte/ASDU"); + AppendField(builder, "Estimated Ethernet frame", stream.EstimatedEthernetBytes > 0 ? $"{stream.EstimatedEthernetBytes} byte" : "-"); + AppendField(builder, "Estimated bandwidth", stream.EstimatedBandwidthBitsPerSecond > 0 + ? $"{stream.EstimatedBandwidthBitsPerSecond / 1_000_000.0:0.###} Mbit/s" + : "-"); + AppendField(builder, "Signal source", EmptyAsDash(stream.SignalSource)); + AppendField(builder, "Quality", EmptyAsDash(stream.Quality)); + AppendField(builder, "Synchronization", EmptyAsDash(stream.SyncMode)); + builder.AppendLine(); + builder.AppendLine("#### Findings"); + builder.AppendLine(); + AppendFindings(builder, stream.Findings); + } + + private static void AppendFindings( + StringBuilder builder, + IReadOnlyList findings) + { + if (findings.Count == 0) + { + builder.AppendLine("- None."); + builder.AppendLine(); + return; + } + + foreach (var finding in findings) + { + builder.Append("- **") + .Append(EscapeInline(EmptyAsDash(finding.Severity))) + .Append("** · ") + .Append(EscapeInline(EmptyAsDash(finding.Area))) + .Append(" · ") + .Append(EscapeInline(EmptyAsDash(finding.Message))); + if (!string.IsNullOrWhiteSpace(finding.Detail)) + builder.Append(" — ").Append(EscapeInline(finding.Detail)); + builder.AppendLine(); + } + builder.AppendLine(); + } + + private static void AppendField(StringBuilder builder, string label, string value) + => builder.Append("- **").Append(label).Append(":** ").AppendLine(EscapeInline(value)); + + private static string EmptyAsDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static string EscapeInline(string value) + => value.Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); +} diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs index 61e7df7..9820ea8 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs @@ -6,6 +6,8 @@ namespace AR.Iec61850.SampledValues; public sealed class SampledValuesPublisherProfile { + public const ushort MaxAsduPerFrame = 8; + private SampledValuesPublisherProfile( SclSampledValuesStream stream, ushort appId, @@ -25,6 +27,7 @@ private SampledValuesPublisherProfile( public VlanTag? Vlan { get; } public SampledValuesPayloadLayout PayloadLayout { get; } public IReadOnlyList Entries => Stream.Entries; + public ushort AsduPerFrame => ResolveAsduPerFrame(Stream); public static IReadOnlyList CreateMany(SclDocument document) { @@ -51,32 +54,59 @@ public SampledValuesFrame CreateFrame( ushort sampleCount, ReadOnlySpan samplePayload, Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2) + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + { + if (AsduPerFrame != 1) + throw new InvalidOperationException($"SV {Stream.ControlBlockReference} declares nofASDU={AsduPerFrame}. Use the multi-ASDU CreateFrame overload."); + + return CreateFrame( + source, + sampleCount, + new[] { samplePayload.ToArray() }, + referenceTime, + sampleSynchronization, + sampleCounterWrap); + } + + public SampledValuesFrame CreateFrame( + MacAddress source, + ushort sampleCount, + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) { + ArgumentNullException.ThrowIfNull(samplePayloads); + ValidateAsduPayloadBatch(samplePayloads); + + if (sampleCounterWrap is 1) + throw new ArgumentOutOfRangeException(nameof(sampleCounterWrap), "SV sample counter wrap must be greater than 1 when supplied."); + + var asdus = new List(samplePayloads.Count); + for (var index = 0; index < samplePayloads.Count; index++) + { + asdus.Add(new SampledValueAsdu + { + SvId = Stream.SvId, + DataSetReference = Stream.DataSetReference, + SampleCount = SampleCounterPolicy.Increment(sampleCount, sampleCounterWrap, index), + ConfigurationRevision = Stream.ConfigurationRevision, + ReferenceTime = referenceTime, + SampleSynchronization = sampleSynchronization, + SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, + SampleMode = TryMapSampleMode(Stream.SampleMode), + SamplePayload = samplePayloads[index].ToArray() + }); + } + return new SampledValuesFrame { Destination = Destination, Source = source, Vlan = Vlan, AppId = AppId, - Pdu = new SampledValuesPdu - { - Asdus = - [ - new SampledValueAsdu - { - SvId = Stream.SvId, - DataSetReference = Stream.DataSetReference, - SampleCount = sampleCount, - ConfigurationRevision = Stream.ConfigurationRevision, - ReferenceTime = referenceTime, - SampleSynchronization = sampleSynchronization, - SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, - SampleMode = TryMapSampleMode(Stream.SampleMode), - SamplePayload = samplePayload.ToArray() - } - ] - } + Pdu = new SampledValuesPdu { Asdus = asdus } }; } @@ -85,10 +115,20 @@ public byte[] BuildEthernetFrame( ushort sampleCount, ReadOnlySpan samplePayload, Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2) - { - return SampledValuesFrameBuilder.BuildEthernetFrame(CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization)); - } + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + => SampledValuesFrameBuilder.BuildEthernetFrame( + CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization, sampleCounterWrap)); + + public byte[] BuildEthernetFrame( + MacAddress source, + ushort sampleCount, + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + => SampledValuesFrameBuilder.BuildEthernetFrame( + CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization, sampleCounterWrap)); public byte[] BuildPayload(IReadOnlyList values) => SampledValuesPayloadBuilder.BuildPayload(PayloadLayout, values); @@ -100,8 +140,9 @@ public byte[] BuildDemoPayload( long sampleIndex, double sampleRateHz, double nominalHz, - Iec61850UtcTime? timestamp = null) - => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp); + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) + => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp, quality); public ushort? ResolveSampleCounterWrap(double nominalFrequencyHz) { @@ -122,26 +163,55 @@ public byte[] BuildDemoPayload( return (ushort)Math.Round(samplesPerSecond); } - private static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) + public static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.Address.AppId.HasValue) throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid APPID in SCL Communication/SMV."); - if (!stream.Address.DestinationMac.HasValue) throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid destination MAC in SCL Communication/SMV."); - if (string.IsNullOrWhiteSpace(stream.SvId)) throw new SclProfileException($"SV {stream.ControlBlockReference} has no svID/smvID."); - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) throw new SclProfileException($"SV {stream.ControlBlockReference} has no resolved DataSet entries."); - if (stream.NoAsdu != 1) - throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher currently supports exactly one ASDU per frame."); + var noAsdu = ResolveAsduPerFrame(stream); + if (noAsdu > MaxAsduPerFrame) + throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher supports up to {MaxAsduPerFrame} ASDUs per frame."); return new SampledValuesPublisherProfile(stream, stream.Address.AppId.Value, stream.Address.DestinationMac.Value, stream.Address.ToVlanTag()); } + public static ushort ResolveAsduPerFrame(SclSampledValuesStream stream) + { + ArgumentNullException.ThrowIfNull(stream); + return stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu; + } + + public static double ResolvePublicationRate(double sampleRateHz, ushort noAsdu) + { + if (!double.IsFinite(sampleRateHz) || sampleRateHz <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0 and finite."); + if (noAsdu == 0) + throw new ArgumentOutOfRangeException(nameof(noAsdu), "nofASDU must be greater than 0."); + + return sampleRateHz / noAsdu; + } + + private void ValidateAsduPayloadBatch(IReadOnlyList samplePayloads) + { + var expected = AsduPerFrame; + if (samplePayloads.Count != expected) + throw new ArgumentException($"SV {Stream.ControlBlockReference} expects nofASDU={expected}, got {samplePayloads.Count} payload(s).", nameof(samplePayloads)); + + for (var index = 0; index < samplePayloads.Count; index++) + { + if (samplePayloads[index].Length != PayloadLayout.PayloadByteLength) + throw new ArgumentException($"SV ASDU payload {index} has {samplePayloads[index].Length} byte(s), expected {PayloadLayout.PayloadByteLength}.", nameof(samplePayloads)); + } + } + private static ushort? TryMapSampleMode(string sampleMode) { if (string.IsNullOrWhiteSpace(sampleMode)) diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs index 3d85ae5..0224a41 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs @@ -38,19 +38,35 @@ public async ValueTask PublishNextAsync( { ArgumentNullException.ThrowIfNull(samplePayload); - var sampleCount = NextSampleCount; - NextSampleCount = IncrementSampleCount(sampleCount, SampleCounterWrap); + if (_profile.AsduPerFrame != 1) + throw new InvalidOperationException($"SV {_profile.Stream.ControlBlockReference} declares nofASDU={_profile.AsduPerFrame}. Use PublishNextBatchAsync."); - var frame = _profile.BuildEthernetFrame(_source, sampleCount, samplePayload, referenceTime, sampleSynchronization); - await _transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); - return frame; + return await PublishNextBatchAsync( + [samplePayload], + referenceTime, + sampleSynchronization, + cancellationToken).ConfigureAwait(false); } - private static ushort IncrementSampleCount(ushort current, ushort? wrap) + public async ValueTask PublishNextBatchAsync( + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + CancellationToken cancellationToken = default) { - if (wrap is > 1) - return current + 1 >= wrap.Value ? (ushort)0 : (ushort)(current + 1); + ArgumentNullException.ThrowIfNull(samplePayloads); + + var sampleCount = NextSampleCount; + NextSampleCount = SampleCounterPolicy.Increment(sampleCount, SampleCounterWrap, samplePayloads.Count); - return current == ushort.MaxValue ? (ushort)0 : (ushort)(current + 1); + var frame = _profile.BuildEthernetFrame( + _source, + sampleCount, + samplePayloads, + referenceTime, + sampleSynchronization, + SampleCounterWrap); + await _transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + return frame; } } diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs new file mode 100644 index 0000000..10c6c54 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs @@ -0,0 +1,97 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues; + +public enum SampledValuesPublisherValidationSeverity +{ + Info, + Warning, + Error +} + +public sealed record SampledValuesPublisherValidationFinding( + SampledValuesPublisherValidationSeverity Severity, + string Code, + string Message, + string Detail = ""); + +public sealed class SampledValuesPublisherValidationReport +{ + public SampledValuesPublisherValidationReport(IReadOnlyList findings) + { + Findings = findings; + } + + public IReadOnlyList Findings { get; } + public bool HasErrors => Findings.Any(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); + public int ErrorCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); + public int WarningCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Warning); + public int InfoCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Info); +} + +public static class SampledValuesPublisherValidator +{ + public static SampledValuesPublisherValidationReport Validate(SclSampledValuesStream stream) + { + ArgumentNullException.ThrowIfNull(stream); + var findings = new List(); + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_STREAM", stream.ControlBlockReference, $"svID={Text(stream.SvId)}, datSet={Text(stream.DataSetReference)}"); + + if (!stream.Address.AppId.HasValue) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_APPID_MISSING", "APPID is missing.", "Add Communication/SubNetwork/ConnectedAP/SMV/Address/P type=APPID."); + else if (stream.Address.AppId.Value == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_APPID_ZERO", "APPID is 0x0000.", "Use the APPID expected by the subscriber unless this is intentional."); + if (!stream.Address.DestinationMac.HasValue) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_MISSING", "Destination MAC is missing or invalid.", stream.Address.DestinationMacText); + else + ValidateDestinationMac(stream.Address.DestinationMac.Value, findings); + + if (string.IsNullOrWhiteSpace(stream.SvId)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_ID_MISSING", "svID/smvID is missing."); + if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DATASET_MISSING", "DataSet cannot be resolved.", stream.DataSetName); + + if (stream.ConfigurationRevision == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_CONFREV_ZERO", "confRev is 0.", "Most engineering files use a positive configuration revision."); + if (stream.SampleRate == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_SMPRATE_MISSING", "smpRate is missing or 0.", "The operator must select an explicit sample rate in the publisher application."); + + var noAsdu = stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu; + if (noAsdu > SampledValuesPublisherProfile.MaxAsduPerFrame) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_NOFASDU_UNSUPPORTED", $"nofASDU={noAsdu} is above the supported limit.", $"This publisher supports 1..{SampledValuesPublisherProfile.MaxAsduPerFrame} ASDU(s) per frame."); + else if (noAsdu > 1) + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_NOFASDU_PACKING", $"nofASDU={noAsdu} ASDU(s) per Ethernet frame.", "The publisher will pack sequential samples into one SavPdu."); + + var layout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); + if (!layout.IsFullySupported) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_PAYLOAD_UNSUPPORTED", "Unsupported SV payload layout.", string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}"))); + else + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_PAYLOAD_LAYOUT", "Payload layout supported.", $"entries={stream.Entries.Count}, payload={layout.PayloadByteLength} bytes per ASDU."); + + return new SampledValuesPublisherValidationReport(findings); + } + + private static void ValidateDestinationMac(MacAddress mac, List findings) + { + var bytes = mac.ToArray(); + if (bytes.All(value => value == 0)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_ZERO", "Destination MAC is all zeros.", mac.ToString()); + else if (bytes.All(value => value == 0xFF)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_BROADCAST", "Destination MAC should not be broadcast for SV.", mac.ToString()); + else if ((bytes[0] & 0x01) == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_UNICAST", "Destination MAC is not multicast.", mac.ToString()); + else if (!(bytes[0] == 0x01 && bytes[1] == 0x0C && bytes[2] == 0xCD && bytes[3] == 0x04)) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_RANGE", "Destination MAC is multicast but outside the common SV multicast range.", mac.ToString()); + } + + private static void Add( + List findings, + SampledValuesPublisherValidationSeverity severity, + string code, + string message, + string detail = "") + => findings.Add(new SampledValuesPublisherValidationFinding(severity, code, message, detail)); + + private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "-" : value; +} diff --git a/src/AR.Iec61850/SampledValues/TxTimingHealth.cs b/src/AR.Iec61850/SampledValues/TxTimingHealth.cs new file mode 100644 index 0000000..90879bd --- /dev/null +++ b/src/AR.Iec61850/SampledValues/TxTimingHealth.cs @@ -0,0 +1,181 @@ +using System.Diagnostics; + +namespace AR.Iec61850.SampledValues; + +public enum TxTimingHealthStatus +{ + Idle, + Good, + Warning, + Bad +} + +public sealed record TxTimingHealthSnapshot( + TxTimingHealthStatus Status, + long FrameCount, + double TargetFramesPerSecond, + double ActualFramesPerSecond, + double TargetIntervalMicroseconds, + double AverageAbsJitterMicroseconds, + double MaxAbsJitterMicroseconds, + long LateFrameCount, + long MissedScheduleCount, + double AverageSendDurationMicroseconds, + double MaxSendDurationMicroseconds, + double MaxLateByMicroseconds) +{ + public string StatusLabel => Status switch + { + TxTimingHealthStatus.Good => "GOOD", + TxTimingHealthStatus.Warning => "WARN", + TxTimingHealthStatus.Bad => "BAD", + _ => "IDLE" + }; +} + +/// +/// Measures publisher-side transmit timing only. This class does not capture or analyze network traffic; +/// it records the schedule, send start, and send completion timestamps from the local SV publisher loop. +/// +public sealed class TxTimingHealth +{ + private readonly long _timestampFrequency; + private readonly long _targetIntervalTicks; + private readonly long _lateThresholdTicks; + private long _frameCount; + private long _firstSendStartTicks; + private long _lastSendStartTicks; + private double _sumAbsJitterTicks; + private long _maxAbsJitterTicks; + private long _intervalCount; + private long _lateFrameCount; + private long _missedScheduleCount; + private long _maxLateByTicks; + private double _sumSendDurationTicks; + private long _maxSendDurationTicks; + + public TxTimingHealth(double targetFramesPerSecond) + : this(targetFramesPerSecond, Stopwatch.Frequency) + { + } + + public TxTimingHealth(double targetFramesPerSecond, long timestampFrequency) + { + if (targetFramesPerSecond <= 0 || double.IsNaN(targetFramesPerSecond) || double.IsInfinity(targetFramesPerSecond)) + throw new ArgumentOutOfRangeException(nameof(targetFramesPerSecond), "Target frame rate must be a positive finite value."); + if (timestampFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(timestampFrequency), "Timestamp frequency must be positive."); + + TargetFramesPerSecond = targetFramesPerSecond; + _timestampFrequency = timestampFrequency; + _targetIntervalTicks = Math.Max(1, (long)Math.Round(timestampFrequency / targetFramesPerSecond)); + _lateThresholdTicks = Math.Max(MicrosecondsToTicks(25), _targetIntervalTicks / 10); + } + + public double TargetFramesPerSecond { get; } + public double TargetIntervalMicroseconds => TicksToMicroseconds(_targetIntervalTicks); + public long FrameCount => _frameCount; + + public void Record(long scheduledTicks, long sendStartTicks, long sendEndTicks) + { + if (sendEndTicks < sendStartTicks) + sendEndTicks = sendStartTicks; + + if (_frameCount == 0) + { + _firstSendStartTicks = sendStartTicks; + } + else + { + var actualIntervalTicks = Math.Max(0, sendStartTicks - _lastSendStartTicks); + var jitterTicks = Math.Abs(actualIntervalTicks - _targetIntervalTicks); + _sumAbsJitterTicks += jitterTicks; + _maxAbsJitterTicks = Math.Max(_maxAbsJitterTicks, jitterTicks); + _intervalCount++; + } + + var lateByTicks = sendStartTicks - scheduledTicks; + if (lateByTicks > 0) + { + _maxLateByTicks = Math.Max(_maxLateByTicks, lateByTicks); + if (lateByTicks > _lateThresholdTicks) + _lateFrameCount++; + if (lateByTicks > _targetIntervalTicks) + _missedScheduleCount += Math.Max(1, lateByTicks / _targetIntervalTicks); + } + + var sendDurationTicks = Math.Max(0, sendEndTicks - sendStartTicks); + _sumSendDurationTicks += sendDurationTicks; + _maxSendDurationTicks = Math.Max(_maxSendDurationTicks, sendDurationTicks); + _lastSendStartTicks = sendStartTicks; + _frameCount++; + } + + public TxTimingHealthSnapshot Snapshot(long nowTicks) + { + if (_frameCount == 0) + { + return new TxTimingHealthSnapshot( + TxTimingHealthStatus.Idle, + 0, + TargetFramesPerSecond, + 0, + TargetIntervalMicroseconds, + 0, + 0, + 0, + 0, + 0, + 0, + 0); + } + + var elapsedTicks = Math.Max(1, nowTicks - _firstSendStartTicks); + var elapsedSeconds = elapsedTicks / (double)_timestampFrequency; + var actualFramesPerSecond = _frameCount / Math.Max(elapsedSeconds, 0.000001); + var averageAbsJitter = _intervalCount == 0 ? 0 : TicksToMicroseconds(_sumAbsJitterTicks / _intervalCount); + var maxAbsJitter = TicksToMicroseconds(_maxAbsJitterTicks); + var averageSendDuration = TicksToMicroseconds(_sumSendDurationTicks / _frameCount); + var maxSendDuration = TicksToMicroseconds(_maxSendDurationTicks); + var maxLateBy = TicksToMicroseconds(_maxLateByTicks); + var status = ResolveStatus(actualFramesPerSecond, maxAbsJitter, _lateFrameCount, _missedScheduleCount); + + return new TxTimingHealthSnapshot( + status, + _frameCount, + TargetFramesPerSecond, + actualFramesPerSecond, + TargetIntervalMicroseconds, + averageAbsJitter, + maxAbsJitter, + _lateFrameCount, + _missedScheduleCount, + averageSendDuration, + maxSendDuration, + maxLateBy); + } + + private TxTimingHealthStatus ResolveStatus(double actualFramesPerSecond, double maxAbsJitterMicroseconds, long lateFrameCount, long missedScheduleCount) + { + if (_frameCount < 8) + return TxTimingHealthStatus.Good; + + var actualRatio = actualFramesPerSecond / TargetFramesPerSecond; + var targetIntervalUs = TargetIntervalMicroseconds; + var lateRatio = lateFrameCount / (double)Math.Max(1, _frameCount); + + if (missedScheduleCount > 0 || actualRatio < 0.98 || maxAbsJitterMicroseconds > targetIntervalUs) + return TxTimingHealthStatus.Bad; + + if (lateRatio > 0.01 || actualRatio < 0.995 || maxAbsJitterMicroseconds > targetIntervalUs * 0.25) + return TxTimingHealthStatus.Warning; + + return TxTimingHealthStatus.Good; + } + + private long MicrosecondsToTicks(double microseconds) + => Math.Max(1, (long)Math.Round(microseconds * _timestampFrequency / 1_000_000.0)); + + private double TicksToMicroseconds(double ticks) + => ticks * 1_000_000.0 / _timestampFrequency; +} diff --git a/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs b/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs new file mode 100644 index 0000000..3578b4c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs @@ -0,0 +1,27 @@ +using AR.Iec61850.Ethernet; + +namespace AR.Iec61850.Tests.Ethernet; + +public sealed class MacAddressValueSemanticsTests +{ + [Fact] + public void EquivalentParsedAddressesCompareByOctetValue() + { + var colon = MacAddress.Parse("01:0C:CD:04:00:02"); + var hyphen = MacAddress.Parse("01-0c-cd-04-00-02"); + + Assert.Equal(colon, hyphen); + Assert.True(colon == hyphen); + Assert.Equal(colon.GetHashCode(), hyphen.GetHashCode()); + } + + [Fact] + public void DifferentAddressesDoNotCompareEqual() + { + var first = MacAddress.Parse("01:0C:CD:04:00:01"); + var second = MacAddress.Parse("01:0C:CD:04:00:02"); + + Assert.NotEqual(first, second); + Assert.True(first != second); + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs new file mode 100644 index 0000000..eca55d3 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs @@ -0,0 +1,207 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Measurements; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvAnalysisCoreTests +{ + [Fact] + public void GenericPayloadInspectorPreservesRawRepresentationsWithoutSemantics() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[] + { + 0x00, 0x00, 0x03, 0xE8, + 0xFF, 0xFF, 0xFC, 0x18 + }); + + Assert.Equal(8, inspection.PayloadLength); + Assert.True(inspection.HasEightByteGroupShape); + Assert.Equal(2, inspection.Words.Count); + Assert.Equal(1000, inspection.Words[0].SignedInt32); + Assert.Equal(-1000, inspection.Words[1].SignedInt32); + Assert.Equal("Word 1", inspection.Words[0].GenericLabel); + Assert.Contains(inspection.Diagnostics, item => item.Contains("structural evidence only", StringComparison.Ordinal)); + } + + [Fact] + public void GenericPayloadInspectorPreservesTrailingBytes() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[] + { + 0x00, 0x00, 0x00, 0x01, + 0xAA, 0xBB + }); + + Assert.False(inspection.IsFourByteAligned); + Assert.Equal(1, inspection.CompleteWordCount); + Assert.Equal(2, inspection.TrailingByteCount); + Assert.Equal(new byte[] { 0xAA, 0xBB }, inspection.TrailingBytes); + } + + [Fact] + public void GenericAsduInspectorExposesWireFieldsButKeepsMappingUnresolved() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + DataSetReference = "MU01/LLN0$Dataset1", + SampleCount = 37, + ConfigurationRevision = 4, + SampleSynchronization = 2, + SampleRate = 4000, + SampleMode = 1, + SamplePayload = new byte[8] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Equal("MU01", inspection.SvId); + Assert.Equal((ushort)37, inspection.SampleCount); + Assert.Contains("bind SCL", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Contains("smpRate", inspection.OptionalFieldSummary, StringComparison.Ordinal); + } + + [Fact] + public void ScalingNeverInfersEngineeringUnitsWithoutSclBinding() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "Ia", + Kind = "Current", + IsSclBound = false, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal("count", scale.Unit); + Assert.Equal(1000, scale.Apply(1000)); + } + + [Fact] + public void ScalingRequiresSclLayoutAndProtectionRateEvidence() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000 + }); + + Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); + Assert.Equal("A", scale.Unit); + Assert.Equal(1.0, scale.Apply(1000), 9); + } + + [Theory] + [InlineData(4000, 50)] + [InlineData(4800, 60)] + public void TimebaseResolverUsesEvidenceForLegacyProtectionRate(double rate, double frequency) + { + var result = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + ObservedSamplesPerSecond = rate, + IsFixedLegacyProtectionLayout = true + }); + + Assert.Equal(frequency, result.NominalFrequencyHz); + Assert.Equal(80, result.SamplesPerCycle); + Assert.Equal((ushort)rate, result.SampleCounterWrap); + } + + [Fact] + public void TimebaseResolverDoesNotInventFrequencyForUnknownLayout() + { + var result = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000, + IsFixedLegacyProtectionLayout = false + }); + + Assert.Null(result.NominalFrequencyHz); + Assert.Null(result.SamplesPerCycle); + Assert.Equal((ushort)4000, result.SampleCounterWrap); + } + + [Fact] + public void CounterTrackerDistinguishesNormalWrapAndGap() + { + var tracker = new SvSampleCounterTracker(); + tracker.Observe(3998, 4000); + tracker.Observe(3999, 4000); + Assert.Equal(SvSampleCounterTransitionKind.NormalWrap, tracker.Observe(0, 4000).Kind); + + var gap = tracker.Observe(3, 4000); + Assert.Equal(SvSampleCounterTransitionKind.Gap, gap.Kind); + Assert.Equal(2, gap.MissingSamples); + } + + [Fact] + public void QualityDecoderReportsSemanticFlagsAndSeverity() + { + var state = SvQualityDecoder.DecodeWord(1 << 6); + + Assert.True(state.Failure); + Assert.Equal(SvQualityValidity.Good, state.Validity); + Assert.Equal(SvQualitySeverity.Bad, state.Severity); + Assert.Contains("failure", state.ActiveFlags); + } + + [Fact] + public void MeasurementRatioConvertsOnlyFromExplicitContext() + { + var ratio = new SvMeasurementRatio + { + PrimaryNominal = 1000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.DeviceConfiguration, + Reference = "reviewed setting" + }; + + var value = SvMeasurementDomainResolver.Resolve( + 500, + "A", + SvMeasurementValueDomain.PrimaryEngineering, + ratio); + + Assert.Equal(500.0, Assert.IsType(value.PrimaryValue)); + Assert.Equal(0.5, Assert.IsType(value.SecondaryEquivalentValue), 9); + Assert.Equal(SvRatioSource.DeviceConfiguration, value.RatioSource); + } + + [Fact] + public void MeasurementContextJsonRoundTripsAndRejectsDuplicateKeys() + { + var context = new SvStreamMeasurementContext + { + StreamKey = "stream-1", + SvId = "MU01", + CurrentRatio = new SvMeasurementRatio + { + PrimaryNominal = 1000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.Manual + } + }; + var document = new SvMeasurementContextDocument { Streams = [context] }; + + var restored = SvMeasurementContextSerializer.FromJson( + SvMeasurementContextSerializer.ToJson(document)); + Assert.Equal("MU01", Assert.Single(restored.Streams).SvId); + + var duplicate = document with { Streams = [context, context] }; + Assert.Throws(() => SvMeasurementContextSerializer.ToJson(duplicate)); + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs new file mode 100644 index 0000000..93f23d2 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Measurements; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvPublisherSupportTests +{ + [Fact] + public void PublisherQualityRoundTripsNetworkBitField() + { + var quality = new SampledValueQuality( + SampledValueValidity.Questionable, + Overflow: true, + OldData: true, + Test: true, + OperatorBlocked: true); + + var restored = SampledValueQuality.FromUInt32(quality.ToUInt32()); + + Assert.Equal(quality, restored); + Assert.Equal(new byte[] { 0x00, 0x00, 0x18, 0x87 }, quality.ToBytes()); + } + + [Theory] + [InlineData(0, SvQualityValidity.Good)] + [InlineData(1, SvQualityValidity.Invalid)] + [InlineData(2, SvQualityValidity.Reserved)] + [InlineData(3, SvQualityValidity.Questionable)] + public void SemanticQualityDecoderUsesIecValidityEncoding(ushort word, SvQualityValidity expected) + => Assert.Equal(expected, SvQualityDecoder.DecodeWord(word).Validity); + + [Fact] + public void PublisherEvidenceWriterRecordsStreamAndSafetyBoundary() + { + var report = new SampledValuesPublisherEvidenceReport( + ToolName: "Test Publisher", + ToolVersion: "1.0.0", + CreatedAt: new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero), + SclPath: "station.scd", + Adapter: "Lab NIC", + Mode: "dry-run", + TxTiming: "TX Timing: GOOD", + SafetyBoundary: "TX-side evidence only", + Streams: + [ + new SampledValuesEvidenceStream( + SlotName: "Publisher 1", + IsEnabled: true, + ControlBlockReference: "IED1/LLN0.SMV1", + SvId: "MU01", + DataSetReference: "IED1/LLN0$DS1", + AppId: "0x4000", + SourceMac: "02:00:00:00:00:01", + DestinationMac: "01:0C:CD:04:00:01", + Vlan: "VID=1/PCP=4", + SampleRateHz: 4000, + PublicationRateHz: 4000, + NoAsdu: 1, + PayloadBytesPerAsdu: 64, + EstimatedEthernetBytes: 126, + EstimatedBandwidthBitsPerSecond: 4_032_000, + SignalSource: "Manual phasor", + Quality: "good", + SyncMode: "LocalCompatibility", + Status: "ready", + Findings: []) + ], + GlobalFindings: []); + + var markdown = SampledValuesPublisherEvidenceReportWriter.ToMarkdown(report); + + Assert.Contains("# Test Publisher Sampled Values Publisher Evidence Report", markdown, StringComparison.Ordinal); + Assert.Contains("TX-side publisher evidence only", markdown, StringComparison.Ordinal); + Assert.Contains("MU01", markdown, StringComparison.Ordinal); + Assert.Contains("TX-side evidence only", markdown, StringComparison.Ordinal); + Assert.Contains("4.032 Mbit/s", markdown, StringComparison.Ordinal); + } + + [Fact] + public void TxTimingHealthReportsGoodForOnScheduleFrames() + { + const double rate = 1000; + var monitor = new TxTimingHealth(rate); + var intervalTicks = (long)Math.Round(Stopwatch.Frequency / rate); + var start = Stopwatch.GetTimestamp(); + + for (var index = 0; index < 100; index++) + { + var scheduled = start + (index * intervalTicks); + monitor.Record(scheduled, scheduled, scheduled + ToTicks(25)); + } + + var snapshot = monitor.Snapshot(start + (100 * intervalTicks)); + + Assert.Equal(TxTimingHealthStatus.Good, snapshot.Status); + Assert.Equal(100, snapshot.FrameCount); + Assert.InRange(snapshot.ActualFramesPerSecond, 990, 1010); + Assert.Equal(0, snapshot.MissedScheduleCount); + } + + [Fact] + public void TxTimingHealthReportsBadWhenOneIntervalIsMissedAfterWarmup() + { + const double rate = 1000; + var monitor = new TxTimingHealth(rate); + var intervalTicks = (long)Math.Round(Stopwatch.Frequency / rate); + var start = Stopwatch.GetTimestamp(); + + for (var index = 0; index < 8; index++) + { + var scheduled = start + (index * intervalTicks); + monitor.Record(scheduled, scheduled, scheduled + ToTicks(25)); + } + + var missedSchedule = start + (8 * intervalTicks); + monitor.Record( + missedSchedule, + missedSchedule + intervalTicks + ToTicks(100), + missedSchedule + intervalTicks + ToTicks(150)); + var snapshot = monitor.Snapshot(start + (10 * intervalTicks)); + + Assert.Equal(TxTimingHealthStatus.Bad, snapshot.Status); + Assert.True(snapshot.MissedScheduleCount >= 1); + Assert.True(snapshot.MaxLateByMicroseconds > 1000); + } + + private static long ToTicks(double microseconds) + => (long)Math.Round(microseconds * Stopwatch.Frequency / 1_000_000.0); +} diff --git a/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs b/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs index feeeb9f..e215299 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs @@ -14,7 +14,7 @@ public void SampledValues_Profile_FromScl_Builds_Parseable_Frame() var profile = SampledValuesPublisherProfile.FromScl(document, "MU01LD0/LLN0$SV$MSVCB01"); var source = MacAddress.Parse("02:00:00:00:10:01"); var referenceTime = new Iec61850UtcTime(new DateTimeOffset(2026, 6, 12, 11, 0, 0, TimeSpan.Zero), Quality: 0); - var payload = Convert.FromHexString("0000006400000001000000C800000003"); + var payload = profile.BuildDefaultPayload(referenceTime); var bytes = profile.BuildEthernetFrame(source, sampleCount: 44, payload, referenceTime); @@ -83,15 +83,25 @@ public void Goose_Profile_Rejects_Value_Count_Mismatch() } [Fact] - public void SampledValues_Profile_Rejects_MultiAsdu_Stream_Until_Supported() + public void SampledValues_Profile_Supports_MultiAsdu_Stream_With_Sequential_Counters() { var xml = File.ReadAllText(SclParserTests.MinimalStationPath()) .Replace("nofASDU=\"1\"", "nofASDU=\"2\"", StringComparison.Ordinal); var document = new AR.Iec61850.Scl.SclParser().Parse(xml, "multi-asdu.scd"); + var profile = SampledValuesPublisherProfile.FromScl(document); + var source = MacAddress.Parse("02:00:00:00:10:01"); + var payload = profile.BuildDefaultPayload(); - void Action() => _ = SampledValuesPublisherProfile.FromScl(document); - var error = Assert.Throws(Action); + var bytes = profile.BuildEthernetFrame( + source, + sampleCount: 3999, + samplePayloads: [payload, payload], + sampleCounterWrap: 4000); - Assert.Contains("nofASDU=2", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(SampledValuesFrameParser.TryParseEthernetFrame(bytes, out var parsed)); + Assert.Equal(2, parsed.Pdu.Asdus.Count); + Assert.Equal((ushort)3999, parsed.Pdu.Asdus[0].SampleCount); + Assert.Equal((ushort)0, parsed.Pdu.Asdus[1].SampleCount); + Assert.All(parsed.Pdu.Asdus, asdu => Assert.Equal(payload, asdu.SamplePayload)); } }