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.
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);
+ }
+ }
+}