From bcecea84b50c628ddce699f1ddafa966aa3255f8 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:09:45 +0700 Subject: [PATCH 1/2] P4A: add field analysis core and PCAPNG support --- .../Capture/ProcessBusCaptureFileReader.cs | 256 ++++++++++++++++++ .../SampledValues/Field/SvFieldHealth.cs | 150 ++++++++++ .../SampledValues/Field/SvKnownInjection.cs | 112 ++++++++ .../Field/SvOptionalFieldPresence.cs | 59 ++++ .../SampledValues/Field/SvSclBindingScorer.cs | 184 +++++++++++++ .../SampledValues/Field/SvSignalAnalysis.cs | 145 ++++++++++ .../SampledValues/Field/SvSupportBundle.cs | 117 ++++++++ .../Profiles/SvStreamObservationManager.cs | 29 +- .../ProcessBusCaptureFileReaderTests.cs | 67 +++++ .../SampledValues/SvFieldCoreTests.cs | 130 +++++++++ 10 files changed, 1245 insertions(+), 4 deletions(-) create mode 100644 src/AR.Iec61850/Capture/ProcessBusCaptureFileReader.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvFieldHealth.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvKnownInjection.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvOptionalFieldPresence.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvSclBindingScorer.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvSignalAnalysis.cs create mode 100644 src/AR.Iec61850/SampledValues/Field/SvSupportBundle.cs create mode 100644 tests/AR.Iec61850.Tests/Capture/ProcessBusCaptureFileReaderTests.cs create mode 100644 tests/AR.Iec61850.Tests/SampledValues/SvFieldCoreTests.cs diff --git a/src/AR.Iec61850/Capture/ProcessBusCaptureFileReader.cs b/src/AR.Iec61850/Capture/ProcessBusCaptureFileReader.cs new file mode 100644 index 0000000..ae7cea9 --- /dev/null +++ b/src/AR.Iec61850/Capture/ProcessBusCaptureFileReader.cs @@ -0,0 +1,256 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.Capture; + +/// +/// Reads Ethernet packets from classic PCAP and PCAPNG capture files. The reader is deliberately +/// independent of Sampled Values decoding so live and offline traffic can share one protocol pipeline. +/// +public static class ProcessBusCaptureFileReader +{ + private const uint SectionHeaderBlock = 0x0A0D0D0A; + private const uint InterfaceDescriptionBlock = 0x00000001; + private const uint EnhancedPacketBlock = 0x00000006; + private const uint SimplePacketBlock = 0x00000003; + private const int MaximumPacketLength = 16 * 1024 * 1024; + private const int MaximumBlockLength = 32 * 1024 * 1024; + + public static IEnumerable Read(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + using var stream = File.OpenRead(path); + foreach (var packet in Read(stream)) + yield return packet; + } + + public static IEnumerable Read(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead) + throw new ArgumentException("Capture stream must be readable.", nameof(stream)); + + var prefix = ReadExact(stream, 4, allowEndOfStream: false); + stream.Position = stream.CanSeek + ? stream.Position - prefix.Length + : throw new ArgumentException("Capture stream must support seeking for format detection.", nameof(stream)); + + if (prefix.AsSpan().SequenceEqual(new byte[] { 0x0A, 0x0D, 0x0D, 0x0A })) + { + foreach (var packet in ReadPcapNg(stream)) + yield return packet; + yield break; + } + + foreach (var packet in ReadClassicPcap(stream)) + yield return packet; + } + + private static IEnumerable ReadClassicPcap(Stream stream) + { + var header = ReadExact(stream, 24, allowEndOfStream: false); + var magic = header.AsSpan(0, 4); + var littleEndian = magic.SequenceEqual(new byte[] { 0xD4, 0xC3, 0xB2, 0xA1 }) || + magic.SequenceEqual(new byte[] { 0x4D, 0x3C, 0xB2, 0xA1 }); + var bigEndian = magic.SequenceEqual(new byte[] { 0xA1, 0xB2, 0xC3, 0xD4 }) || + magic.SequenceEqual(new byte[] { 0xA1, 0xB2, 0x3C, 0x4D }); + if (!littleEndian && !bigEndian) + throw new InvalidDataException("Capture is neither classic PCAP nor PCAPNG."); + + var nanosecondResolution = magic.SequenceEqual(new byte[] { 0x4D, 0x3C, 0xB2, 0xA1 }) || + magic.SequenceEqual(new byte[] { 0xA1, 0xB2, 0x3C, 0x4D }); + var linkType = ReadUInt32(header, 20, littleEndian); + if (linkType != 1) + throw new InvalidDataException($"Unsupported classic PCAP link type {linkType}; Ethernet link type 1 is required."); + + while (true) + { + var packetHeader = ReadExact(stream, 16, allowEndOfStream: true); + if (packetHeader.Length == 0) + yield break; + if (packetHeader.Length != 16) + throw new InvalidDataException("Classic PCAP packet header is truncated."); + + var seconds = ReadUInt32(packetHeader, 0, littleEndian); + var fraction = ReadUInt32(packetHeader, 4, littleEndian); + var includedLength = ReadUInt32(packetHeader, 8, littleEndian); + if (includedLength == 0 || includedLength > MaximumPacketLength) + throw new InvalidDataException($"Invalid PCAP packet length {includedLength}."); + + var frame = ReadExact(stream, checked((int)includedLength), allowEndOfStream: false); + var ticks = nanosecondResolution ? fraction / 100.0 : fraction * 10.0; + var timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds).AddTicks((long)Math.Round(ticks)); + yield return new PcapPacket(timestamp, frame); + } + } + + private static IEnumerable ReadPcapNg(Stream stream) + { + var littleEndian = true; + var sectionSeen = false; + var interfaces = new List(); + + while (true) + { + var header = ReadExact(stream, 8, allowEndOfStream: true); + if (header.Length == 0) + yield break; + if (header.Length != 8) + throw new InvalidDataException("PCAPNG block header is truncated."); + + var rawTypeLittle = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0, 4)); + if (rawTypeLittle == SectionHeaderBlock) + { + var byteOrder = ReadExact(stream, 4, allowEndOfStream: false); + littleEndian = byteOrder.AsSpan().SequenceEqual(new byte[] { 0x4D, 0x3C, 0x2B, 0x1A }) + ? true + : byteOrder.AsSpan().SequenceEqual(new byte[] { 0x1A, 0x2B, 0x3C, 0x4D }) + ? false + : throw new InvalidDataException("PCAPNG section byte-order magic is invalid."); + + var totalLength = ReadUInt32(header, 4, littleEndian); + ValidateBlockLength(totalLength, 28); + var remaining = ReadExact(stream, checked((int)totalLength - 12), allowEndOfStream: false); + ValidateTrailingLength(remaining, totalLength, littleEndian); + sectionSeen = true; + interfaces.Clear(); + continue; + } + + if (!sectionSeen) + throw new InvalidDataException("PCAPNG does not begin with a Section Header Block."); + + var blockType = ReadUInt32(header, 0, littleEndian); + var blockLength = ReadUInt32(header, 4, littleEndian); + ValidateBlockLength(blockLength, 12); + var blockRemainder = ReadExact(stream, checked((int)blockLength - 8), allowEndOfStream: false); + ValidateTrailingLength(blockRemainder, blockLength, littleEndian); + var body = blockRemainder.AsSpan(0, blockRemainder.Length - 4).ToArray(); + + if (blockType == InterfaceDescriptionBlock) + { + interfaces.Add(ParseInterface(body, littleEndian)); + continue; + } + + if (blockType == EnhancedPacketBlock) + { + var packet = ParseEnhancedPacket(body, littleEndian, interfaces); + if (packet is not null) + yield return packet; + continue; + } + + if (blockType == SimplePacketBlock) + { + // Simple Packet Blocks do not carry a timestamp. Keep offline engineering evidence + // deterministic by skipping them rather than inventing capture time. + continue; + } + } + } + + private static PcapNgInterface ParseInterface(byte[] body, bool littleEndian) + { + if (body.Length < 8) + throw new InvalidDataException("PCAPNG Interface Description Block is truncated."); + + var linkType = ReadUInt16(body, 0, littleEndian); + var resolution = 1e-6; + var offset = 8; + while (offset + 4 <= body.Length) + { + var code = ReadUInt16(body, offset, littleEndian); + var length = ReadUInt16(body, offset + 2, littleEndian); + offset += 4; + if (code == 0) + break; + if (offset + length > body.Length) + throw new InvalidDataException("PCAPNG interface option is truncated."); + + if (code == 9 && length >= 1) + { + var value = body[offset]; + resolution = (value & 0x80) == 0 + ? Math.Pow(10, -(value & 0x7F)) + : Math.Pow(2, -(value & 0x7F)); + } + + offset += Align4(length); + } + + return new PcapNgInterface(linkType, resolution); + } + + private static PcapPacket? ParseEnhancedPacket(byte[] body, bool littleEndian, IReadOnlyList interfaces) + { + if (body.Length < 20) + throw new InvalidDataException("PCAPNG Enhanced Packet Block is truncated."); + + var interfaceId = ReadUInt32(body, 0, littleEndian); + if (interfaceId >= interfaces.Count) + throw new InvalidDataException($"PCAPNG packet references unknown interface {interfaceId}."); + + var captureInterface = interfaces[(int)interfaceId]; + if (captureInterface.LinkType != 1) + return null; + + var timestampHigh = ReadUInt32(body, 4, littleEndian); + var timestampLow = ReadUInt32(body, 8, littleEndian); + var capturedLength = ReadUInt32(body, 12, littleEndian); + if (capturedLength == 0 || capturedLength > MaximumPacketLength) + throw new InvalidDataException($"Invalid PCAPNG packet length {capturedLength}."); + if (20L + capturedLength > body.Length) + throw new InvalidDataException("PCAPNG packet data is truncated."); + + var units = ((ulong)timestampHigh << 32) | timestampLow; + var seconds = units * captureInterface.TimestampResolutionSeconds; + var ticks = checked((long)Math.Round(seconds * TimeSpan.TicksPerSecond)); + var frame = body.AsSpan(20, checked((int)capturedLength)).ToArray(); + return new PcapPacket(DateTimeOffset.UnixEpoch.AddTicks(ticks), frame); + } + + private static byte[] ReadExact(Stream stream, int length, bool allowEndOfStream) + { + var buffer = new byte[length]; + var offset = 0; + while (offset < length) + { + var read = stream.Read(buffer, offset, length - offset); + if (read == 0) + { + if (offset == 0 && allowEndOfStream) + return []; + throw new InvalidDataException("Capture file is truncated."); + } + offset += read; + } + return buffer; + } + + private static void ValidateBlockLength(uint length, int minimum) + { + if (length < minimum || length > MaximumBlockLength || length % 4 != 0) + throw new InvalidDataException($"Invalid PCAPNG block length {length}."); + } + + private static void ValidateTrailingLength(byte[] remainder, uint expected, bool littleEndian) + { + if (remainder.Length < 4) + throw new InvalidDataException("PCAPNG block trailer is missing."); + var trailing = ReadUInt32(remainder, remainder.Length - 4, littleEndian); + if (trailing != expected) + throw new InvalidDataException($"PCAPNG block length mismatch: header {expected}, trailer {trailing}."); + } + + private static int Align4(int length) => (length + 3) & ~3; + private static ushort ReadUInt16(byte[] source, int offset, bool littleEndian) + => littleEndian + ? BinaryPrimitives.ReadUInt16LittleEndian(source.AsSpan(offset, 2)) + : BinaryPrimitives.ReadUInt16BigEndian(source.AsSpan(offset, 2)); + private static uint ReadUInt32(byte[] source, int offset, bool littleEndian) + => littleEndian + ? BinaryPrimitives.ReadUInt32LittleEndian(source.AsSpan(offset, 4)) + : BinaryPrimitives.ReadUInt32BigEndian(source.AsSpan(offset, 4)); + + private sealed record PcapNgInterface(ushort LinkType, double TimestampResolutionSeconds); +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvFieldHealth.cs b/src/AR.Iec61850/SampledValues/Field/SvFieldHealth.cs new file mode 100644 index 0000000..0e3a772 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvFieldHealth.cs @@ -0,0 +1,150 @@ +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Field; + +public enum SvFieldHealthState +{ + Unknown, + Good, + Quiet, + Warning, + Bad +} + +public sealed record SvFieldHealthAxis( + string Name, + SvFieldHealthState State, + string Summary, + IReadOnlyList Evidence) +{ + public static SvFieldHealthAxis Unknown(string name, string summary) + => new(name, SvFieldHealthState.Unknown, summary, Array.Empty()); +} + +public sealed record SvFieldHealthReport +{ + public SvFieldHealthAxis Capture { get; init; } = SvFieldHealthAxis.Unknown("CAPTURE", "No capture evidence"); + public SvFieldHealthAxis Protocol { get; init; } = SvFieldHealthAxis.Unknown("PROTOCOL", "No protocol evidence"); + public SvFieldHealthAxis Stream { get; init; } = SvFieldHealthAxis.Unknown("STREAM", "No continuity evidence"); + public SvFieldHealthAxis Configuration { get; init; } = SvFieldHealthAxis.Unknown("CONFIGURATION", "No SCL context"); + public SvFieldHealthAxis Measurement { get; init; } = SvFieldHealthAxis.Unknown("MEASUREMENT", "Measurement semantics unresolved"); + + /// Operational status uses only capture, protocol, and stream continuity. + public SvFieldHealthState OperationalState => Worst(Capture.State, Protocol.State, Stream.State); + + /// Review status describes configuration and measurement confidence without declaring the stream broken. + public SvFieldHealthState ReviewState => Worst(Configuration.State, Measurement.State); + + private static SvFieldHealthState Worst(params SvFieldHealthState[] states) + => states.OrderByDescending(Rank).FirstOrDefault(); + + private static int Rank(SvFieldHealthState state) => state switch + { + SvFieldHealthState.Bad => 5, + SvFieldHealthState.Warning => 4, + SvFieldHealthState.Quiet => 3, + SvFieldHealthState.Good => 2, + _ => 1 + }; +} + +public sealed record SvFieldHealthInput +{ + public long RawFrameCount { get; init; } + public long SvFrameCount { get; init; } + public long ParseErrorCount { get; init; } + public int SequenceGapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderCount { get; init; } + public int PayloadIssueCount { get; init; } + public SvConfigurationComparisonResult? ConfigurationComparison { get; init; } + public bool IsSclBound { get; init; } + public bool HasSemanticMapping { get; init; } + public bool HasEngineeringScaling { get; init; } + public bool IsScalingValidated { get; init; } + public SvSignalAnalysis? Signal { get; init; } +} + +public static class SvFieldHealthEvaluator +{ + public static SvFieldHealthReport Evaluate(SvFieldHealthInput input) + { + ArgumentNullException.ThrowIfNull(input); + + var capture = EvaluateCapture(input); + var protocol = EvaluateProtocol(input); + var stream = EvaluateStream(input); + var configuration = EvaluateConfiguration(input); + var measurement = EvaluateMeasurement(input); + return new SvFieldHealthReport + { + Capture = capture, + Protocol = protocol, + Stream = stream, + Configuration = configuration, + Measurement = measurement + }; + } + + private static SvFieldHealthAxis EvaluateCapture(SvFieldHealthInput input) + { + if (input.RawFrameCount <= 0) + return SvFieldHealthAxis.Unknown("CAPTURE", "No frames observed"); + if (input.SvFrameCount <= 0) + return new("CAPTURE", SvFieldHealthState.Warning, "Frames received, no SV decoded", [$"raw={input.RawFrameCount:N0}"]); + return new("CAPTURE", SvFieldHealthState.Good, $"{input.SvFrameCount:N0} SV frame(s) available", [$"raw={input.RawFrameCount:N0}"]); + } + + private static SvFieldHealthAxis EvaluateProtocol(SvFieldHealthInput input) + { + if (input.SvFrameCount <= 0 && input.ParseErrorCount <= 0) + return SvFieldHealthAxis.Unknown("PROTOCOL", "No SV protocol evidence"); + if (input.SvFrameCount <= 0 && input.ParseErrorCount > 0) + return new("PROTOCOL", SvFieldHealthState.Bad, "SV frames could not be decoded", [$"parse errors={input.ParseErrorCount:N0}"]); + if (input.ParseErrorCount > 0) + return new("PROTOCOL", SvFieldHealthState.Warning, "Decoded SV with parse errors also present", [$"parse errors={input.ParseErrorCount:N0}"]); + return new("PROTOCOL", SvFieldHealthState.Good, "Ethernet and SV APDU decode cleanly", Array.Empty()); + } + + private static SvFieldHealthAxis EvaluateStream(SvFieldHealthInput input) + { + if (input.SvFrameCount <= 0) + return SvFieldHealthAxis.Unknown("STREAM", "No continuity window"); + if (input.OutOfOrderCount > 0 || input.PayloadIssueCount > 0) + return new("STREAM", SvFieldHealthState.Bad, "Continuity or payload integrity failed", + [$"out-of-order={input.OutOfOrderCount}", $"payload={input.PayloadIssueCount}"]); + if (input.SequenceGapCount > 0 || input.DuplicateCount > 0) + return new("STREAM", SvFieldHealthState.Warning, "Continuity requires review", + [$"gaps={input.SequenceGapCount}", $"duplicates={input.DuplicateCount}"]); + return new("STREAM", SvFieldHealthState.Good, "Sample continuity is healthy", Array.Empty()); + } + + private static SvFieldHealthAxis EvaluateConfiguration(SvFieldHealthInput input) + { + if (!input.IsSclBound || input.ConfigurationComparison is null) + return SvFieldHealthAxis.Unknown("CONFIGURATION", "No SCL/CID binding"); + + var comparison = input.ConfigurationComparison; + if (comparison.ErrorCount > 0) + return new("CONFIGURATION", SvFieldHealthState.Bad, comparison.Summary, + comparison.Findings.Select(finding => $"{finding.Code}: {finding.Message}").ToArray()); + if (comparison.WarningCount > 0) + return new("CONFIGURATION", SvFieldHealthState.Warning, comparison.Summary, + comparison.Findings.Select(finding => $"{finding.Code}: {finding.Message}").ToArray()); + return new("CONFIGURATION", SvFieldHealthState.Good, "Observed stream matches bound SCL", Array.Empty()); + } + + private static SvFieldHealthAxis EvaluateMeasurement(SvFieldHealthInput input) + { + if (!input.HasSemanticMapping) + return SvFieldHealthAxis.Unknown("MEASUREMENT", "Raw values available; channel semantics unresolved"); + if (!input.HasEngineeringScaling) + return new("MEASUREMENT", SvFieldHealthState.Warning, "Channels mapped; engineering scaling unresolved", Array.Empty()); + + if (input.Signal?.State is SvSignalActivityState.Quiet or SvSignalActivityState.NoiseDominated) + return new("MEASUREMENT", SvFieldHealthState.Quiet, "QUIET / NOISE FLOOR", [input.Signal.Summary]); + if (!input.IsScalingValidated) + return new("MEASUREMENT", SvFieldHealthState.Warning, "Engineering values are provisional", Array.Empty()); + return new("MEASUREMENT", SvFieldHealthState.Good, "Engineering interpretation validated", Array.Empty()); + } +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvKnownInjection.cs b/src/AR.Iec61850/SampledValues/Field/SvKnownInjection.cs new file mode 100644 index 0000000..dc0c8f5 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvKnownInjection.cs @@ -0,0 +1,112 @@ +namespace AR.Iec61850.SampledValues.Field; + +public enum SvKnownInjectionResultState +{ + Unresolved, + Review, + Pass, + Fail +} + +public sealed record SvKnownInjectionExpectation +{ + public string Channel { get; init; } = string.Empty; + public double ExpectedRms { get; init; } + public string Unit { get; init; } = string.Empty; + public string Domain { get; init; } = string.Empty; + public double? ExpectedAngleDegrees { get; init; } + public double? ExpectedFrequencyHz { get; init; } + public double? AmplitudeTolerancePercent { get; init; } + public double? AngleToleranceDegrees { get; init; } + public double? FrequencyToleranceHz { get; init; } +} + +public sealed record SvKnownInjectionMeasurement +{ + public double MeasuredRms { get; init; } + public double? MeasuredAngleDegrees { get; init; } + public double? MeasuredFrequencyHz { get; init; } +} + +public sealed record SvKnownInjectionComparison +{ + public SvKnownInjectionResultState State { get; init; } + public double AbsoluteAmplitudeError { get; init; } + public double? AmplitudeErrorPercent { get; init; } + public double? AngleErrorDegrees { get; init; } + public double? FrequencyErrorHz { get; init; } + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public static class SvKnownInjectionComparator +{ + public static SvKnownInjectionComparison Compare( + SvKnownInjectionExpectation expected, + SvKnownInjectionMeasurement measured) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(measured); + if (string.IsNullOrWhiteSpace(expected.Channel) || !double.IsFinite(expected.ExpectedRms) || expected.ExpectedRms < 0 || + !double.IsFinite(measured.MeasuredRms) || measured.MeasuredRms < 0) + return new SvKnownInjectionComparison { State = SvKnownInjectionResultState.Unresolved, Diagnostics = ["Expected and measured RMS must be finite non-negative values and channel must be identified."] }; + + var absolute = measured.MeasuredRms - expected.ExpectedRms; + double? percent = Math.Abs(expected.ExpectedRms) <= double.Epsilon + ? null + : absolute / expected.ExpectedRms * 100.0; + double? angleError = expected.ExpectedAngleDegrees.HasValue && measured.MeasuredAngleDegrees.HasValue + ? NormalizeAngle(measured.MeasuredAngleDegrees.Value - expected.ExpectedAngleDegrees.Value) + : null; + double? frequencyError = expected.ExpectedFrequencyHz.HasValue && measured.MeasuredFrequencyHz.HasValue + ? measured.MeasuredFrequencyHz.Value - expected.ExpectedFrequencyHz.Value + : null; + + var diagnostics = new List(); + var toleranceSpecified = false; + var failed = false; + if (expected.AmplitudeTolerancePercent.HasValue) + { + toleranceSpecified = true; + if (!percent.HasValue || Math.Abs(percent.Value) > expected.AmplitudeTolerancePercent.Value) + { + failed = true; + diagnostics.Add("Amplitude error exceeds tolerance."); + } + } + if (expected.AngleToleranceDegrees.HasValue) + { + toleranceSpecified = true; + if (!angleError.HasValue || Math.Abs(angleError.Value) > expected.AngleToleranceDegrees.Value) + { + failed = true; + diagnostics.Add("Angle error exceeds tolerance."); + } + } + if (expected.FrequencyToleranceHz.HasValue) + { + toleranceSpecified = true; + if (!frequencyError.HasValue || Math.Abs(frequencyError.Value) > expected.FrequencyToleranceHz.Value) + { + failed = true; + diagnostics.Add("Frequency error exceeds tolerance."); + } + } + + return new SvKnownInjectionComparison + { + State = !toleranceSpecified ? SvKnownInjectionResultState.Review : failed ? SvKnownInjectionResultState.Fail : SvKnownInjectionResultState.Pass, + AbsoluteAmplitudeError = absolute, + AmplitudeErrorPercent = percent, + AngleErrorDegrees = angleError, + FrequencyErrorHz = frequencyError, + Diagnostics = diagnostics + }; + } + + private static double NormalizeAngle(double value) + { + while (value > 180) value -= 360; + while (value <= -180) value += 360; + return value; + } +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvOptionalFieldPresence.cs b/src/AR.Iec61850/SampledValues/Field/SvOptionalFieldPresence.cs new file mode 100644 index 0000000..3b143a7 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvOptionalFieldPresence.cs @@ -0,0 +1,59 @@ +namespace AR.Iec61850.SampledValues.Field; + +public sealed record SvOptionalFieldPresence +{ + public bool DataSetReferencePresent { get; init; } + public bool ReferenceTimePresent { get; init; } + public bool SampleRatePresent { get; init; } + public bool SampleModePresent { get; init; } + public bool SvIdPresent { get; init; } + public bool SampleSynchronizationPresent { get; init; } + public IReadOnlyList PresentFields { get; init; } = Array.Empty(); + public IReadOnlyList AbsentOptionalFields { get; init; } = Array.Empty(); + + public string Summary => AbsentOptionalFields.Count == 0 + ? "All modeled optional fields are present" + : $"Optional fields not present on wire: {string.Join(", ", AbsentOptionalFields)}"; +} + +public static class SvOptionalFieldInspector +{ + public static SvOptionalFieldPresence Inspect(SampledValueAsdu asdu) + { + ArgumentNullException.ThrowIfNull(asdu); + var present = new List(); + var absent = new List(); + + Add("svID", !string.IsNullOrWhiteSpace(asdu.SvId), required: true, present, absent); + Add("datSet", !string.IsNullOrWhiteSpace(asdu.DataSetReference), required: false, present, absent); + Add("refrTm", asdu.ReferenceTime.HasValue, required: false, present, absent); + Add("smpRate", asdu.SampleRate.HasValue, required: false, present, absent); + Add("smpMod", asdu.SampleMode.HasValue, required: false, present, absent); + Add("smpSynch", true, required: true, present, absent); + + return new SvOptionalFieldPresence + { + DataSetReferencePresent = !string.IsNullOrWhiteSpace(asdu.DataSetReference), + ReferenceTimePresent = asdu.ReferenceTime.HasValue, + SampleRatePresent = asdu.SampleRate.HasValue, + SampleModePresent = asdu.SampleMode.HasValue, + SvIdPresent = !string.IsNullOrWhiteSpace(asdu.SvId), + SampleSynchronizationPresent = true, + PresentFields = present, + AbsentOptionalFields = absent + }; + } + + private static void Add( + string name, + bool isPresent, + bool required, + ICollection present, + ICollection absent) + { + if (isPresent) + present.Add(name); + else if (!required) + absent.Add(name); + } +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvSclBindingScorer.cs b/src/AR.Iec61850/SampledValues/Field/SvSclBindingScorer.cs new file mode 100644 index 0000000..e4884a8 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvSclBindingScorer.cs @@ -0,0 +1,184 @@ +namespace AR.Iec61850.SampledValues.Field; + +public enum SvSclBindingConfidence +{ + Rejected, + Unknown, + Possible, + Likely, + Confirmed +} + +public enum SvBindingEvidenceOutcome +{ + Match, + Conflict, + Unknown +} + +public sealed record SvSclBindingCandidate +{ + public string CandidateId { get; init; } = string.Empty; + public ushort? ExpectedAppId { get; init; } + public string ExpectedDestinationMac { get; init; } = string.Empty; + public ushort? ExpectedVlanId { get; init; } + public string ExpectedSvId { get; init; } = string.Empty; + public string ExpectedDataSetReference { get; init; } = string.Empty; + public uint? ExpectedConfigurationRevision { get; init; } + public int? ExpectedAsduPerFrame { get; init; } + public int? ExpectedPayloadBytesPerAsdu { get; init; } +} + +public sealed record SvSclBindingObservation +{ + public ushort AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { 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 sealed record SvSclBindingEvidence( + string Field, + SvBindingEvidenceOutcome Outcome, + int Weight, + string Expected, + string Observed, + string Message, + bool IsBlocking = false); + +public sealed record SvSclBindingResult +{ + public string CandidateId { get; init; } = string.Empty; + public SvSclBindingConfidence Confidence { get; init; } + public int Score { get; init; } + public int EvaluatedWeight { get; init; } + public IReadOnlyList Evidence { get; init; } = Array.Empty(); + public bool HasBlockingConflict => Evidence.Any(item => item.IsBlocking && item.Outcome == SvBindingEvidenceOutcome.Conflict); + public string Summary => $"{Confidence} · score {Score}%"; +} + +/// +/// Scores SCL candidates without requiring optional datSet on the wire. APPID and destination MAC +/// are blocking identity evidence; confRev is reported as configuration evidence, not a parser selector. +/// +public static class SvSclBindingScorer +{ + public static SvSclBindingResult Score(SvSclBindingCandidate candidate, SvSclBindingObservation observation) + { + ArgumentNullException.ThrowIfNull(candidate); + ArgumentNullException.ThrowIfNull(observation); + var evidence = new List(); + + Compare(evidence, "APPID", candidate.ExpectedAppId, observation.AppId, 30, value => $"0x{value:X4}", blocking: true); + CompareText(evidence, "Destination MAC", candidate.ExpectedDestinationMac, observation.DestinationMac, 25, NormalizeMac, blocking: true); + CompareText(evidence, "svID", candidate.ExpectedSvId, observation.SvId, 25, NormalizeText, blocking: false); + Compare(evidence, "Payload bytes/ASDU", candidate.ExpectedPayloadBytesPerAsdu, observation.PayloadBytesPerAsdu, 10, value => value.ToString(), blocking: false); + Compare(evidence, "ASDU/frame", candidate.ExpectedAsduPerFrame, observation.AsduPerFrame, 5, value => value.ToString(), blocking: false); + Compare(evidence, "VLAN ID", candidate.ExpectedVlanId, observation.VlanId, 5, value => value.ToString(), blocking: false); + + // datSet is optional on the wire. Missing observed data is unknown, never a conflict. + CompareText(evidence, "datSet", candidate.ExpectedDataSetReference, observation.DataSetReference, 10, NormalizeText, blocking: false); + + if (candidate.ExpectedConfigurationRevision.HasValue) + { + var outcome = observation.ConfigurationRevision.HasValue + ? candidate.ExpectedConfigurationRevision.Value == observation.ConfigurationRevision.Value + ? SvBindingEvidenceOutcome.Match + : SvBindingEvidenceOutcome.Conflict + : SvBindingEvidenceOutcome.Unknown; + evidence.Add(new SvSclBindingEvidence( + "confRev", + outcome, + 0, + candidate.ExpectedConfigurationRevision.Value.ToString(), + observation.ConfigurationRevision?.ToString() ?? "-", + outcome == SvBindingEvidenceOutcome.Conflict + ? "Configuration revision differs; keep binding evidence but raise CONFIGURATION warning." + : "Configuration revision evidence.", + false)); + } + + var evaluated = evidence.Where(item => item.Weight > 0 && item.Outcome != SvBindingEvidenceOutcome.Unknown).Sum(item => item.Weight); + var matched = evidence.Where(item => item.Outcome == SvBindingEvidenceOutcome.Match).Sum(item => item.Weight); + var score = evaluated == 0 ? 0 : (int)Math.Round(matched * 100.0 / evaluated); + var blocking = evidence.Any(item => item.IsBlocking && item.Outcome == SvBindingEvidenceOutcome.Conflict); + var confidence = blocking + ? SvSclBindingConfidence.Rejected + : score >= 85 && evaluated >= 70 + ? SvSclBindingConfidence.Confirmed + : score >= 65 && evaluated >= 55 + ? SvSclBindingConfidence.Likely + : score >= 40 + ? SvSclBindingConfidence.Possible + : SvSclBindingConfidence.Unknown; + + return new SvSclBindingResult + { + CandidateId = candidate.CandidateId, + Confidence = confidence, + Score = score, + EvaluatedWeight = evaluated, + Evidence = evidence + }; + } + + private static void Compare( + ICollection evidence, + string field, + T? expected, + T? observed, + int weight, + Func format, + bool blocking) where T : struct, IEquatable + { + if (!expected.HasValue) + return; + if (!observed.HasValue) + { + evidence.Add(new(field, SvBindingEvidenceOutcome.Unknown, weight, format(expected.Value), "-", $"Observed {field} is unavailable.", blocking)); + return; + } + var match = expected.Value.Equals(observed.Value); + evidence.Add(new(field, match ? SvBindingEvidenceOutcome.Match : SvBindingEvidenceOutcome.Conflict, weight, + format(expected.Value), format(observed.Value), match ? $"{field} matches." : $"{field} differs.", blocking)); + } + + private static void Compare( + ICollection evidence, + string field, + T? expected, + T observed, + int weight, + Func format, + bool blocking) where T : struct, IEquatable + => Compare(evidence, field, expected, (T?)observed, weight, format, blocking); + + private static void CompareText( + ICollection evidence, + string field, + string expected, + string observed, + int weight, + Func normalize, + bool blocking) + { + if (string.IsNullOrWhiteSpace(expected)) + return; + if (string.IsNullOrWhiteSpace(observed)) + { + evidence.Add(new(field, SvBindingEvidenceOutcome.Unknown, weight, expected, "-", $"Observed {field} is not present on wire.", blocking)); + return; + } + var match = string.Equals(normalize(expected), normalize(observed), StringComparison.Ordinal); + evidence.Add(new(field, match ? SvBindingEvidenceOutcome.Match : SvBindingEvidenceOutcome.Conflict, weight, + expected, observed, match ? $"{field} matches." : $"{field} differs.", blocking)); + } + + private static string NormalizeText(string value) => value.Trim(); + private static string NormalizeMac(string value) + => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvSignalAnalysis.cs b/src/AR.Iec61850/SampledValues/Field/SvSignalAnalysis.cs new file mode 100644 index 0000000..cee4a7d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvSignalAnalysis.cs @@ -0,0 +1,145 @@ +namespace AR.Iec61850.SampledValues.Field; + +public enum SvSignalActivityState +{ + Unresolved, + Quiet, + NoiseDominated, + Active +} + +public sealed record SvSignalAnalysis +{ + public SvSignalActivityState State { get; init; } + public int SampleCount { get; init; } + public double Median { get; init; } + public double RobustSigma { get; init; } + public double AcRms { get; init; } + public double PeakDeviation { get; init; } + public double? FundamentalRms { get; init; } + public double? ResidualRms { get; init; } + public double? FundamentalSnrDb { get; init; } + public double? QuietThreshold { get; init; } + public string Summary { get; init; } = string.Empty; +} + +public sealed record SvSignalAnalysisOptions +{ + public double? SamplesPerCycle { get; init; } + public double? RatedRms { get; init; } + public double? AbsoluteQuietThreshold { get; init; } + public double QuietRatedFraction { get; init; } = 0.001; + public double MinimumCoherentSnrDb { get; init; } = 6.0; + public int MaximumAnalysisSamples { get; init; } = 4096; +} + +/// +/// Robust signal-state analyzer. It never modifies or clamps samples; classification affects only +/// presentation and evidence. A hard QUIET result requires an explicit rated or absolute threshold. +/// +public static class SvSignalStateAnalyzer +{ + public static SvSignalAnalysis Analyze(IEnumerable samples, SvSignalAnalysisOptions? options = null) + { + ArgumentNullException.ThrowIfNull(samples); + options ??= new SvSignalAnalysisOptions(); + if (options.QuietRatedFraction < 0 || options.MinimumCoherentSnrDb < 0 || options.MaximumAnalysisSamples < 16) + throw new ArgumentOutOfRangeException(nameof(options), "Signal-analysis options are invalid."); + + var values = samples.Where(double.IsFinite).TakeLast(options.MaximumAnalysisSamples).ToArray(); + if (values.Length < 16) + return new SvSignalAnalysis { State = SvSignalActivityState.Unresolved, SampleCount = values.Length, Summary = "Insufficient samples for robust signal classification" }; + + var median = Median(values); + var deviations = values.Select(value => Math.Abs(value - median)).ToArray(); + var mad = Median(deviations); + var robustSigma = 1.4826 * mad; + var centered = values.Select(value => value - median).ToArray(); + var acRms = Math.Sqrt(centered.Average(value => value * value)); + var peak = centered.Max(value => Math.Abs(value)); + var quietThreshold = ResolveQuietThreshold(options); + + double? fundamentalRms = null; + double? residualRms = null; + double? snrDb = null; + if (options.SamplesPerCycle is >= 4 && values.Length >= options.SamplesPerCycle.Value) + { + var omega = 2.0 * Math.PI / options.SamplesPerCycle.Value; + var cosine = 0.0; + var sine = 0.0; + for (var index = 0; index < centered.Length; index++) + { + cosine += centered[index] * Math.Cos(omega * index); + sine += centered[index] * Math.Sin(omega * index); + } + + var peakFundamental = 2.0 * Math.Sqrt((cosine * cosine) + (sine * sine)) / centered.Length; + fundamentalRms = peakFundamental / Math.Sqrt(2.0); + residualRms = Math.Sqrt(Math.Max(0, (acRms * acRms) - (fundamentalRms.Value * fundamentalRms.Value))); + snrDb = residualRms.Value <= double.Epsilon + ? double.PositiveInfinity + : 20.0 * Math.Log10(Math.Max(fundamentalRms.Value, double.Epsilon) / residualRms.Value); + } + + SvSignalActivityState state; + if (quietThreshold.HasValue && acRms <= quietThreshold.Value) + { + state = SvSignalActivityState.Quiet; + } + else if (!fundamentalRms.HasValue || !snrDb.HasValue || snrDb.Value < options.MinimumCoherentSnrDb) + { + state = SvSignalActivityState.NoiseDominated; + } + else + { + state = SvSignalActivityState.Active; + } + + return new SvSignalAnalysis + { + State = state, + SampleCount = values.Length, + Median = median, + RobustSigma = robustSigma, + AcRms = acRms, + PeakDeviation = peak, + FundamentalRms = fundamentalRms, + ResidualRms = residualRms, + FundamentalSnrDb = snrDb, + QuietThreshold = quietThreshold, + Summary = BuildSummary(state, acRms, fundamentalRms, snrDb, quietThreshold) + }; + } + + private static double? ResolveQuietThreshold(SvSignalAnalysisOptions options) + { + var candidates = new[] + { + options.AbsoluteQuietThreshold, + options.RatedRms.HasValue ? options.RatedRms.Value * options.QuietRatedFraction : null + }.Where(value => value is > 0).Select(value => value!.Value).ToArray(); + return candidates.Length == 0 ? null : candidates.Max(); + } + + private static double Median(IReadOnlyCollection source) + { + var ordered = source.Order().ToArray(); + var middle = ordered.Length / 2; + return ordered.Length % 2 == 0 + ? (ordered[middle - 1] + ordered[middle]) / 2.0 + : ordered[middle]; + } + + private static string BuildSummary( + SvSignalActivityState state, + double acRms, + double? fundamentalRms, + double? snrDb, + double? quietThreshold) + { + var fundamental = fundamentalRms.HasValue ? $"fundamental={fundamentalRms.Value:0.###}" : "fundamental=unresolved"; + var snr = snrDb.HasValue ? $"SNR={snrDb.Value:0.##} dB" : "SNR=unresolved"; + var threshold = quietThreshold.HasValue ? $"quiet≤{quietThreshold.Value:0.###}" : "quiet threshold not configured"; + return $"{state} · AC RMS={acRms:0.###} · {fundamental} · {snr} · {threshold}"; + } +} diff --git a/src/AR.Iec61850/SampledValues/Field/SvSupportBundle.cs b/src/AR.Iec61850/SampledValues/Field/SvSupportBundle.cs new file mode 100644 index 0000000..bab7cf6 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Field/SvSupportBundle.cs @@ -0,0 +1,117 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Field; + +public enum SvSupportBundlePrivacyMode +{ + FullEvidence, + CaptureExcerpt, + Anonymized, + MetadataOnly +} + +public sealed record SvSupportBundleManifest +{ + public const string CurrentSchemaVersion = "ariec61850.sv-support-bundle/v1"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset GeneratedAt { get; init; } + public string Application { get; init; } = string.Empty; + public string ApplicationVersion { get; init; } = string.Empty; + public string ApplicationCommit { get; init; } = string.Empty; + public string EngineCommit { get; init; } = string.Empty; + public string CaptureSource { get; init; } = string.Empty; + public string SclSha256 { get; init; } = string.Empty; + public SvSupportBundlePrivacyMode PrivacyMode { get; init; } + public IReadOnlyList Files { get; init; } = Array.Empty(); +} + +public sealed record SvSupportBundleFile( + string Path, + long Length, + string Sha256, + string Purpose); + +public sealed record SvSupportBundleContent( + string Path, + ReadOnlyMemory Content, + string Purpose); + +public static class SvSupportBundleWriter +{ + private static readonly JsonSerializerOptions JsonOptions = CreateOptions(); + + public static void Write( + string zipPath, + SvSupportBundleManifest manifest, + IEnumerable contents) + { + ArgumentException.ThrowIfNullOrWhiteSpace(zipPath); + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(contents); + if (manifest.SchemaVersion != SvSupportBundleManifest.CurrentSchemaVersion || manifest.GeneratedAt == default) + throw new InvalidOperationException("Support bundle manifest is incomplete or uses an unsupported schema."); + + var materialized = contents.ToArray(); + ValidatePaths(materialized.Select(item => item.Path)); + var files = materialized.Select(item => new SvSupportBundleFile( + item.Path, + item.Content.Length, + Convert.ToHexString(SHA256.HashData(item.Content.Span)).ToLowerInvariant(), + item.Purpose)).ToArray(); + var completedManifest = manifest with { Files = files }; + + var directory = Path.GetDirectoryName(Path.GetFullPath(zipPath)); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + if (File.Exists(zipPath)) + File.Delete(zipPath); + + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var item in materialized) + { + var entry = archive.CreateEntry(item.Path.Replace('\\', '/'), CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(item.Content.Span); + } + + var manifestJson = JsonSerializer.Serialize(completedManifest, JsonOptions); + WriteText(archive, "manifest.json", manifestJson); + var checksums = string.Join(Environment.NewLine, files.OrderBy(file => file.Path, StringComparer.Ordinal) + .Select(file => $"{file.Sha256} {file.Path.Replace('\\', '/') }")) + Environment.NewLine; + WriteText(archive, "sha256sums.txt", checksums); + } + + public static SvSupportBundleContent Text(string path, string content, string purpose) + => new(path, Encoding.UTF8.GetBytes(content ?? string.Empty), purpose); + + private static void ValidatePaths(IEnumerable paths) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in paths) + { + var normalized = (path ?? string.Empty).Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("../", StringComparison.Ordinal) || Path.IsPathRooted(normalized)) + throw new InvalidOperationException($"Unsafe support-bundle path '{path}'."); + if (normalized is "manifest.json" or "sha256sums.txt" || !seen.Add(normalized)) + throw new InvalidOperationException($"Duplicate or reserved support-bundle path '{normalized}'."); + } + } + + private static void WriteText(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index 0f70e6d..7f323b9 100644 --- a/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -163,10 +163,31 @@ public IReadOnlyList SnapshotAll() 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; + if (profile is null) + return null; + + var appIdMatches = profile.AppId == frame.AppId; + var destinationMatches = string.Equals(profile.Destination.ToString(), frame.Destination.ToString(), StringComparison.OrdinalIgnoreCase); + if (!appIdMatches || !destinationMatches) + { + diagnostics.Add($"Rejected SCL candidate {profile.Stream.ControlBlockReference}: APPID and destination MAC must identify the same configured stream before comparison."); + return null; + } + + var expectedVlan = profile.Vlan?.VlanId; + var observedVlan = frame.Vlan?.VlanId; + if (expectedVlan.HasValue && observedVlan.HasValue && expectedVlan.Value != observedVlan.Value) + { + diagnostics.Add($"Rejected SCL candidate {profile.Stream.ControlBlockReference}: expected VLAN {expectedVlan.Value}, observed VLAN {observedVlan.Value}."); + return null; + } + + if (expectedVlan.HasValue && !observedVlan.HasValue) + { + diagnostics.Add($"SCL expects VLAN {expectedVlan.Value}, but the capture does not expose an 802.1Q tag. Binding retained because NIC/driver VLAN stripping is possible; verify with a TAP or mirror port."); + } + + return profile; } private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList asdus) diff --git a/tests/AR.Iec61850.Tests/Capture/ProcessBusCaptureFileReaderTests.cs b/tests/AR.Iec61850.Tests/Capture/ProcessBusCaptureFileReaderTests.cs new file mode 100644 index 0000000..80d8e9d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Capture/ProcessBusCaptureFileReaderTests.cs @@ -0,0 +1,67 @@ +using System.Buffers.Binary; +using AR.Iec61850.Capture; + +namespace AR.Iec61850.Tests.Capture; + +public sealed class ProcessBusCaptureFileReaderTests +{ + [Fact] + public void ReadsClassicLittleEndianPcap() + { + var frame = new byte[] { 1, 2, 3, 4, 5, 6 }; + using var stream = new MemoryStream(); + stream.Write(new byte[] { 0xD4, 0xC3, 0xB2, 0xA1 }); + WriteUInt16(stream, 2); WriteUInt16(stream, 4); + WriteUInt32(stream, 0); WriteUInt32(stream, 0); WriteUInt32(stream, 65535); WriteUInt32(stream, 1); + WriteUInt32(stream, 10); WriteUInt32(stream, 250_000); WriteUInt32(stream, (uint)frame.Length); WriteUInt32(stream, (uint)frame.Length); + stream.Write(frame); stream.Position = 0; + + var packet = Assert.Single(ProcessBusCaptureFileReader.Read(stream)); + + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(10).AddMilliseconds(250), packet.Timestamp); + Assert.Equal(frame, packet.Frame); + } + + [Fact] + public void ReadsLittleEndianPcapNgEnhancedPacket() + { + var frame = new byte[] { 0x01, 0x0C, 0xCD, 0x04, 0x00, 0x00, 1, 2, 3, 4, 5, 6, 0x88, 0xBA }; + using var stream = new MemoryStream(); + WriteSectionHeader(stream); + WriteInterfaceDescription(stream); + WriteEnhancedPacket(stream, frame, timestampMicroseconds: 1_500_000); + stream.Position = 0; + + var packet = Assert.Single(ProcessBusCaptureFileReader.Read(stream)); + + Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(1.5), packet.Timestamp); + Assert.Equal(frame, packet.Frame); + } + + private static void WriteSectionHeader(Stream stream) + { + WriteUInt32(stream, 0x0A0D0D0A); WriteUInt32(stream, 28); WriteUInt32(stream, 0x1A2B3C4D); + WriteUInt16(stream, 1); WriteUInt16(stream, 0); WriteUInt64(stream, ulong.MaxValue); WriteUInt32(stream, 28); + } + + private static void WriteInterfaceDescription(Stream stream) + { + WriteUInt32(stream, 1); WriteUInt32(stream, 20); WriteUInt16(stream, 1); WriteUInt16(stream, 0); + WriteUInt32(stream, 65535); WriteUInt32(stream, 20); + } + + private static void WriteEnhancedPacket(Stream stream, byte[] frame, ulong timestampMicroseconds) + { + var paddedLength = (frame.Length + 3) & ~3; + var total = 32 + paddedLength; + WriteUInt32(stream, 6); WriteUInt32(stream, (uint)total); WriteUInt32(stream, 0); + WriteUInt32(stream, (uint)(timestampMicroseconds >> 32)); WriteUInt32(stream, (uint)timestampMicroseconds); + WriteUInt32(stream, (uint)frame.Length); WriteUInt32(stream, (uint)frame.Length); stream.Write(frame); + for (var index = frame.Length; index < paddedLength; index++) stream.WriteByte(0); + WriteUInt32(stream, (uint)total); + } + + private static void WriteUInt16(Stream stream, ushort value) { Span buffer = stackalloc byte[2]; BinaryPrimitives.WriteUInt16LittleEndian(buffer, value); stream.Write(buffer); } + private static void WriteUInt32(Stream stream, uint value) { Span buffer = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(buffer, value); stream.Write(buffer); } + private static void WriteUInt64(Stream stream, ulong value) { Span buffer = stackalloc byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(buffer, value); stream.Write(buffer); } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvFieldCoreTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvFieldCoreTests.cs new file mode 100644 index 0000000..a8a17a7 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvFieldCoreTests.cs @@ -0,0 +1,130 @@ +using System.IO.Compression; +using AR.Iec61850.SampledValues.Field; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvFieldCoreTests +{ + [Fact] + public void ConfigurationWarningDoesNotMakeOperationalStreamBad() + { + var comparison = new SvConfigurationComparisonResult + { + Mode = SvComparisonMode.Compatible, + Findings = [new SvConfigurationFinding(SvConfigurationFindingSeverity.Warning, "SV_CONFREV_MISMATCH", "confRev", "100", "200", "Configuration revision differs.")] + }; + var report = SvFieldHealthEvaluator.Evaluate(new SvFieldHealthInput + { + RawFrameCount = 47_963, + SvFrameCount = 47_938, + ConfigurationComparison = comparison, + IsSclBound = true, + HasSemanticMapping = true, + HasEngineeringScaling = true, + Signal = new SvSignalAnalysis { State = SvSignalActivityState.NoiseDominated, Summary = "noise dominated" } + }); + + Assert.Equal(SvFieldHealthState.Good, report.OperationalState); + Assert.Equal(SvFieldHealthState.Warning, report.Configuration.State); + Assert.Equal(SvFieldHealthState.Quiet, report.Measurement.State); + } + + [Fact] + public void SignalAnalyzerLabelsNoiseDominatedWithoutForcingZero() + { + var random = new Random(61850); + var samples = Enumerable.Range(0, 480) + .Select(_ => random.NextDouble() * 2 - 1) + .ToArray(); + + var result = SvSignalStateAnalyzer.Analyze(samples, new SvSignalAnalysisOptions { SamplesPerCycle = 80 }); + + Assert.Equal(SvSignalActivityState.NoiseDominated, result.State); + Assert.True(result.AcRms > 0); + Assert.NotEqual(0, result.PeakDeviation); + } + + [Fact] + public void SignalAnalyzerDetectsCoherentFundamental() + { + var samples = Enumerable.Range(0, 320) + .Select(index => 100 * Math.Sin(2 * Math.PI * index / 80.0)) + .ToArray(); + + var result = SvSignalStateAnalyzer.Analyze(samples, new SvSignalAnalysisOptions { SamplesPerCycle = 80 }); + + Assert.Equal(SvSignalActivityState.Active, result.State); + Assert.InRange(result.FundamentalRms!.Value, 70.70, 70.72); + } + + [Fact] + public void SclBindingDoesNotRequireOptionalDatasetField() + { + var result = SvSclBindingScorer.Score( + new SvSclBindingCandidate + { + CandidateId = "AA1J1Q01A1/LLN0.MSVCB", + ExpectedAppId = 0x4001, + ExpectedDestinationMac = "01:0C:CD:04:00:00", + ExpectedSvId = "AA1J1Q01A1MU01/LLN0.MSVCB", + ExpectedDataSetReference = "AA1J1Q01A1/LLN0$MSV", + ExpectedConfigurationRevision = 100, + ExpectedAsduPerFrame = 1, + ExpectedPayloadBytesPerAsdu = 64 + }, + new SvSclBindingObservation + { + AppId = 0x4001, + DestinationMac = "01:0C:CD:04:00:00", + SvId = "AA1J1Q01A1MU01/LLN0.MSVCB", + DataSetReference = string.Empty, + ConfigurationRevision = 200, + AsduPerFrame = 1, + PayloadBytesPerAsdu = 64 + }); + + Assert.Equal(SvSclBindingConfidence.Confirmed, result.Confidence); + Assert.Contains(result.Evidence, item => item.Field == "datSet" && item.Outcome == SvBindingEvidenceOutcome.Unknown); + Assert.Contains(result.Evidence, item => item.Field == "confRev" && item.Outcome == SvBindingEvidenceOutcome.Conflict); + } + + [Fact] + public void KnownInjectionWithoutToleranceReturnsReview() + { + var result = SvKnownInjectionComparator.Compare( + new SvKnownInjectionExpectation { Channel = "I01A", ExpectedRms = 0.1, Unit = "A", Domain = "secondary" }, + new SvKnownInjectionMeasurement { MeasuredRms = 0.0997 }); + + Assert.Equal(SvKnownInjectionResultState.Review, result.State); + Assert.InRange(result.AmplitudeErrorPercent!.Value, -0.31, -0.29); + } + + [Fact] + public void SupportBundleContainsManifestAndChecksums() + { + var path = Path.Combine(Path.GetTempPath(), $"arsubsv-{Guid.NewGuid():N}.zip"); + try + { + SvSupportBundleWriter.Write(path, + new SvSupportBundleManifest + { + GeneratedAt = DateTimeOffset.UtcNow, + Application = "ArSubsv", + ApplicationVersion = "0.4.0", + EngineCommit = new string('a', 40), + PrivacyMode = SvSupportBundlePrivacyMode.MetadataOnly + }, + [SvSupportBundleWriter.Text("diagnostics.md", "GOOD", "field diagnostics")]); + + using var archive = ZipFile.OpenRead(path); + Assert.NotNull(archive.GetEntry("manifest.json")); + Assert.NotNull(archive.GetEntry("sha256sums.txt")); + Assert.NotNull(archive.GetEntry("diagnostics.md")); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } +} From e532c46f418d6953605ab850760add316d5db0d6 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 15:13:50 +0700 Subject: [PATCH 2/2] Document the P4 field-core evidence model --- docs/SV_FIELD_CORE_P4.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/SV_FIELD_CORE_P4.md diff --git a/docs/SV_FIELD_CORE_P4.md b/docs/SV_FIELD_CORE_P4.md new file mode 100644 index 0000000..e7e1d03 --- /dev/null +++ b/docs/SV_FIELD_CORE_P4.md @@ -0,0 +1,29 @@ +# Sampled Values field core P4 + +This field layer turns generic protocol evidence into a practical, explicitly qualified workflow without introducing manufacturer-specific decoding. + +## Health axes + +- `CAPTURE`: packet availability to the application. +- `PROTOCOL`: Ethernet and Sampled Values APDU decoding. +- `STREAM`: sample continuity and payload consistency. +- `CONFIGURATION`: expected CID/SCD configuration versus observed traffic. +- `MEASUREMENT`: channel semantics, scaling, CT/VT context, signal activity, and validation confidence. + +Operational health uses CAPTURE, PROTOCOL, and STREAM. Configuration or measurement uncertainty is reported independently and does not make a clean stream BAD. + +## File replay + +`ProcessBusCaptureFileReader` reads classic PCAP and PCAPNG Enhanced Packet Blocks with Ethernet link type. Offline packets feed the same `SampledValuesFrameParser` used by live capture. + +## Signal evidence + +`SvSignalStateAnalyzer` uses robust statistics and a coherent-fundamental estimate. It never changes raw samples. QUIET requires an explicit rated or absolute threshold; otherwise non-coherent small activity is reported as `NoiseDominated` rather than falsely forced to zero. + +## SCL binding + +`SvSclBindingScorer` treats APPID and destination MAC as strong identity evidence, while optional `datSet` may be absent on wire. `confRev` disagreement is retained as configuration evidence. A VLAN tag absent from a host capture can remain indeterminate because NIC or driver offload may strip it. + +## Field acceptance boundary + +Deterministic tests prove software behavior only. Real PCAPNG/CID replay, known injection, and authorized live MU tests remain required before calibrated measurement or universal-interoperability claims.